@skyhook-io/k8s-ui 1.13.0 → 1.13.4

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 (110) hide show
  1. package/package.json +2 -2
  2. package/src/components/applications/AppTooltips.tsx +13 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +25 -0
  4. package/src/components/applications/ApplicationDetail.tsx +41 -9
  5. package/src/components/applications/ApplicationsView.tsx +18 -3
  6. package/src/components/applications/ReadyBar.tsx +3 -2
  7. package/src/components/applications/applications-view.test.tsx +91 -0
  8. package/src/components/cluster-switcher/ClusterSwitcher.tsx +35 -7
  9. package/src/components/issues/IssuesView.tsx +40 -31
  10. package/src/components/issues/ResourceIssuesSection.tsx +3 -2
  11. package/src/components/issues/index.ts +3 -1
  12. package/src/components/issues/issue-timing.ts +123 -4
  13. package/src/components/issues/issues.test.ts +221 -10
  14. package/src/components/issues/types.ts +31 -5
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
  16. package/src/components/resources/ResourcesView.tsx +973 -100
  17. package/src/components/resources/column-settings-key.test.ts +98 -0
  18. package/src/components/resources/column-visibility-merge.test.ts +64 -0
  19. package/src/components/resources/curated-column-ownership.test.ts +236 -0
  20. package/src/components/resources/gateway-policy-status.test.ts +291 -0
  21. package/src/components/resources/gateway-policy-status.ts +237 -0
  22. package/src/components/resources/generic-status.test.ts +199 -0
  23. package/src/components/resources/generic-status.ts +171 -0
  24. package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
  25. package/src/components/resources/index.ts +9 -1
  26. package/src/components/resources/istio-gateway-filter.test.ts +97 -0
  27. package/src/components/resources/printer-columns.test.ts +298 -0
  28. package/src/components/resources/printer-columns.ts +249 -0
  29. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  30. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  31. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  32. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  33. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  34. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  35. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  36. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  37. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  38. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  39. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  40. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  41. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  43. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  44. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  45. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  46. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  47. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  48. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  49. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  50. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  51. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  52. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  53. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  54. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  55. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  56. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  57. package/src/components/resources/resource-utils-hpa.ts +6 -2
  58. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  59. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  60. package/src/components/resources/resource-utils-kai.ts +105 -0
  61. package/src/components/resources/resource-utils-kaito.ts +114 -0
  62. package/src/components/resources/resource-utils-kserve.ts +186 -0
  63. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  64. package/src/components/resources/resource-utils-kueue.ts +198 -0
  65. package/src/components/resources/resource-utils-nim.ts +109 -0
  66. package/src/components/resources/resource-utils-ray.ts +214 -0
  67. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  68. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  69. package/src/components/resources/resource-utils-volcano.ts +176 -0
  70. package/src/components/resources/resource-utils.ts +156 -47
  71. package/src/components/resources/resources-column-filter.test.ts +27 -1
  72. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  73. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  74. package/src/components/shared/EditableYamlView.tsx +4 -2
  75. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  76. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  77. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  78. package/src/components/shared/SetImageDialog.test.ts +129 -0
  79. package/src/components/shared/SetImageDialog.tsx +577 -0
  80. package/src/components/shared/index.ts +1 -0
  81. package/src/components/shared/revision-role-badges.test.ts +56 -4
  82. package/src/components/timeline/TimelineList.tsx +19 -3
  83. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  84. package/src/components/trace/ReachabilityView.tsx +1 -1
  85. package/src/components/trace/TracePanel.tsx +1 -0
  86. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  87. package/src/components/ui/SelectMenu.tsx +202 -28
  88. package/src/components/ui/drawer-components.tsx +12 -0
  89. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  90. package/src/components/workload/WorkloadView.tsx +146 -83
  91. package/src/hooks/useProgressiveRefresh.ts +37 -0
  92. package/src/source-hygiene.test.ts +28 -0
  93. package/src/theme/tailwind-theme.css +4 -0
  94. package/src/types/core.ts +62 -4
  95. package/src/utils/api-resources.test.ts +40 -1
  96. package/src/utils/api-resources.ts +11 -1
  97. package/src/utils/applications.ts +50 -0
  98. package/src/utils/bulk-workload-actions.test.ts +20 -0
  99. package/src/utils/bulk-workload-actions.ts +15 -0
  100. package/src/utils/clipboard.test.ts +86 -0
  101. package/src/utils/clipboard.ts +50 -0
  102. package/src/utils/extended-resources.ts +4 -7
  103. package/src/utils/format.test.ts +83 -1
  104. package/src/utils/format.ts +80 -1
  105. package/src/utils/inClusterConsent.test.ts +9 -0
  106. package/src/utils/inClusterConsent.ts +9 -9
  107. package/src/utils/index.ts +1 -0
  108. package/src/utils/navigation.test.ts +31 -1
  109. package/src/utils/navigation.ts +21 -2
  110. package/src/utils/workload-rollout.ts +292 -0
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo, useEffect, useRef, useCallback, type ComponentType, type ReactNode } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
3
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
4
+ import { useProgressiveRefresh } from '../../hooks/useProgressiveRefresh'
4
5
  import { startViewTransitionSafe } from '../../utils/view-transition'
5
6
  import { FetchResult } from '../ui/FetchResult'
6
7
  import { PaneLoader } from '../ui/PaneLoader'
@@ -30,12 +31,12 @@ import {
30
31
  BarChart3,
31
32
  Network,
32
33
  Stethoscope,
33
- DollarSign,
34
+ Coins,
34
35
  } from 'lucide-react'
35
36
  import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
36
37
  import type { GitOpsStatus } from '../../types/gitops'
37
38
  import type { NavigateToResource } from '../../utils/navigation'
38
- import { refToSelectedResource, pluralToKind, kindToPlural, apiVersionToGroup } from '../../utils/navigation'
39
+ import { refToSelectedResource, pluralToKind, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
39
40
  import { neighborhoodFor, seedNodeIds } from '../../utils/topology-neighborhood'
40
41
  import { TopologyGraph } from '../topology/TopologyGraph'
41
42
  import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
@@ -67,8 +68,12 @@ import {
67
68
  getIngressAddress,
68
69
  getIngressHosts,
69
70
  getServiceExternalIP,
71
+ getWorkloadDisplayStatus,
70
72
  } from '../resources/resource-utils'
71
73
  import { ServicePortCards, type ServicePortRenderProps } from '../resources/renderers/ServiceRenderer'
74
+ import { rolloutMayAdvanceAutomatically, type WorkloadRolloutActivity } from '../../utils/workload-rollout'
75
+ import { WorkloadRolloutNotice } from './WorkloadRolloutNotice'
76
+ import { isCoreBatchJob } from '../../utils/api-resources'
72
77
 
73
78
  export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'reachability' | 'cost' | 'yaml'
74
79
  type TabType = WorkloadTabType
@@ -465,6 +470,23 @@ export function WorkloadView({
465
470
  const [initialContainer, setInitialContainer] = useState<string | null>(null)
466
471
  const [copied, setCopied] = useState<string | null>(null)
467
472
  const [saveSuccess, setSaveSuccess] = useState(false)
473
+ const [recentImageSave, setRecentImageSave] = useState(false)
474
+ const sawImageRollout = useRef(false)
475
+
476
+ const effectiveActionsBarProps = useMemo(() => {
477
+ const onSetImages = actionsBarProps?.onSetImages as ((params: unknown) => Promise<unknown>) | undefined
478
+ if (!onSetImages) return actionsBarProps
479
+ return {
480
+ ...actionsBarProps,
481
+ onSetImages: async (params: unknown) => {
482
+ const result = await onSetImages(params)
483
+ sawImageRollout.current = false
484
+ setRecentImageSave(true)
485
+ refetchProp?.()
486
+ return result
487
+ },
488
+ }
489
+ }, [actionsBarProps, refetchProp])
468
490
 
469
491
  // Refresh animation
470
492
  const [refetch, isRefreshAnimating, refreshPhase] = useRefreshAnimation(refetchProp ?? (() => {}))
@@ -534,11 +556,12 @@ export function WorkloadView({
534
556
  const handleTopologyNodeClick = useCallback(
535
557
  (node: TopologyNode) => {
536
558
  if (!onNavigateToResource || !node.kind || !node.name) return
559
+ const group = apiVersionToGroup(node.data?.apiVersion as string | undefined)
537
560
  onNavigateToResource({
538
- kind: kindToPlural(node.kind),
561
+ kind: kindToPluralWithGroup(node.kind, group),
539
562
  namespace: (node.data?.namespace as string) || '',
540
563
  name: node.name,
541
- group: apiVersionToGroup(node.data?.apiVersion as string | undefined),
564
+ group,
542
565
  })
543
566
  },
544
567
  [onNavigateToResource],
@@ -678,7 +701,24 @@ export function WorkloadView({
678
701
  },
679
702
  ], [active, expanded, onBack, onClose, switchView]))
680
703
 
681
- const status = getResourceStatus(apiKind, resource)
704
+ const rolloutDisplay = resource && isRolloutStatusKind(apiKind)
705
+ ? getWorkloadDisplayStatus(resource, apiKind, workloadPods)
706
+ : null
707
+ const rolloutActivity = rolloutDisplay?.activity ?? null
708
+ const status = rolloutDisplay?.status ?? getResourceStatus(apiKind, resource)
709
+ const rolloutMayAutoAdvance = rolloutActivity ? rolloutMayAdvanceAutomatically(rolloutActivity) : false
710
+ useEffect(() => {
711
+ if (!recentImageSave) return
712
+ if (rolloutActivity?.phase !== 'idle') sawImageRollout.current = true
713
+ if (sawImageRollout.current && rolloutActivity?.phase === 'idle') {
714
+ setRecentImageSave(false)
715
+ sawImageRollout.current = false
716
+ return
717
+ }
718
+ const timeout = window.setTimeout(() => setRecentImageSave(false), 15000)
719
+ return () => window.clearTimeout(timeout)
720
+ }, [recentImageSave, rolloutActivity?.phase])
721
+ useProgressiveRefresh(rolloutMayAutoAdvance || recentImageSave, refetchProp)
682
722
  const showOwnershipHeading = kind === 'Pod' && Boolean(ownershipContext)
683
723
  const headerImage = metadata.find(m => m.label === 'Image')?.value
684
724
 
@@ -699,7 +739,10 @@ export function WorkloadView({
699
739
 
700
740
  const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false
701
741
  const showCostTab = isCostAvailable ? isCostAvailable(kind, resource) : false
702
- const logsTabVisible = Boolean(renderLogsTab) && (allPods.length > 0 || LOGS_TAB_WITHOUT_PODS_KINDS.has(kindToPlural(kind).toLowerCase()))
742
+ const normalizedKind = kindToPlural(kind).toLowerCase()
743
+ const logsWithoutPods = LOGS_TAB_WITHOUT_PODS_KINDS.has(normalizedKind) &&
744
+ (normalizedKind !== 'jobs' || isCoreBatchJob(kind, group))
745
+ const logsTabVisible = Boolean(renderLogsTab) && (allPods.length > 0 || logsWithoutPods)
703
746
  const metricsTabVisible = Boolean(showMetricsTab && renderMetricsTab)
704
747
  const costTabVisible = Boolean(showCostTab && renderCostTab)
705
748
  const podEvidenceLoading = resourceLoading || workloadPodsLoading || eventsLoading
@@ -722,7 +765,7 @@ export function WorkloadView({
722
765
  icon: <Stethoscope className="w-4 h-4" />,
723
766
  hidden: !(renderDiagnoseTab && (isDiagnoseKind(apiKind, group) || (reachableVia?.length ?? 0) > 0)),
724
767
  },
725
- { id: 'cost', label: 'Cost', icon: <DollarSign className="w-4 h-4" />, hidden: !costTabVisible },
768
+ { id: 'cost', label: 'Cost', icon: <Coins className="w-4 h-4" />, hidden: !costTabVisible },
726
769
  { id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
727
770
  ]
728
771
  const requestedTabAvailable = tabs.some((tab) => tab.id === requestedTab && !tab.hidden)
@@ -831,7 +874,7 @@ export function WorkloadView({
831
874
  </div>
832
875
 
833
876
  {/* Actions bar */}
834
- <ResourceActionsBar resource={selectedResource} data={resource} onClose={onClose} showYaml={showYaml} onToggleYaml={() => switchView(!showYaml)} {...actionsBarProps} />
877
+ <ResourceActionsBar resource={selectedResource} data={resource} onClose={onClose} showYaml={showYaml} onToggleYaml={() => switchView(!showYaml)} {...effectiveActionsBarProps} />
835
878
  </div>
836
879
 
837
880
  {/* Success animation overlay */}
@@ -875,6 +918,7 @@ export function WorkloadView({
875
918
  certificateInfo={certificateInfo}
876
919
  hpaDiagnosis={hpaDiagnosis}
877
920
  scalerDiagnostics={scalerDiagnostics}
921
+ workloadPods={workloadPods}
878
922
  onCopy={copyToClipboard}
879
923
  copied={copied}
880
924
  onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
@@ -1010,10 +1054,17 @@ export function WorkloadView({
1010
1054
  activeTab={effectiveTab}
1011
1055
  onTabChange={handleSetTab}
1012
1056
  scopeControls={scopeControls}
1013
- tabStripEnd={<ResourceActionsBar resource={selectedResource} data={resource} hideLogs {...actionsBarProps} />}
1057
+ tabStripEnd={<ResourceActionsBar resource={selectedResource} data={resource} hideLogs {...effectiveActionsBarProps} />}
1014
1058
  overlay={saveSuccess ? <SaveSuccessAnimation /> : null}
1015
1059
  compactHeader={compactHeader}
1016
1060
  >
1061
+ <div className="flex h-full min-h-0 flex-col">
1062
+ {effectiveTab !== 'overview' && rolloutActivity && (rolloutActivity.phase !== 'idle' || recentImageSave) && (
1063
+ <div className="shrink-0 px-4 pt-4">
1064
+ <WorkloadRolloutNotice activity={rolloutActivity} recentImageSave={recentImageSave} />
1065
+ </div>
1066
+ )}
1067
+ <div className="min-h-0 flex-1">
1017
1068
  {effectiveTab === 'overview' && expandedOverview ? (
1018
1069
  <div className="h-full min-h-0">
1019
1070
  {hasOperationalIssues && renderOverviewLead && (
@@ -1059,6 +1110,7 @@ export function WorkloadView({
1059
1110
  introContent={overviewIntro}
1060
1111
  leadContent={hasOperationalIssues && renderOverviewLead ? renderOverviewLead({ kind, namespace, name }) : undefined}
1061
1112
  onEvaluateCapacity={onEvaluateCapacity}
1113
+ recentImageSave={recentImageSave}
1062
1114
  />
1063
1115
  )}
1064
1116
  {effectiveTab === 'topology' && (
@@ -1168,6 +1220,8 @@ export function WorkloadView({
1168
1220
  </div>
1169
1221
  </div>
1170
1222
  )}
1223
+ </div>
1224
+ </div>
1171
1225
  </DetailShell>
1172
1226
  </OperationalIssuesShownContext.Provider>
1173
1227
  )
@@ -1533,6 +1587,7 @@ function InfoTab({
1533
1587
  introContent,
1534
1588
  leadContent,
1535
1589
  onEvaluateCapacity,
1590
+ recentImageSave,
1536
1591
  }: {
1537
1592
  resource: any
1538
1593
  selectedResource: SelectedResource
@@ -1568,12 +1623,13 @@ function InfoTab({
1568
1623
  extraContent?: ReactNode
1569
1624
  introContent?: ReactNode
1570
1625
  leadContent?: ReactNode
1626
+ recentImageSave?: boolean
1571
1627
  }) {
1572
1628
  if (!resource) {
1573
1629
  return <FetchResult loading={isLoading} error={error} className="h-full" />
1574
1630
  }
1575
1631
 
1576
- if (!isRuntimeWorkloadOverviewKind(selectedResource.kind)) {
1632
+ if (!isRuntimeWorkloadOverviewKind(selectedResource.kind, selectedResource.group)) {
1577
1633
  return (
1578
1634
  <div className="h-full overflow-auto">
1579
1635
  {leadContent && (
@@ -1655,6 +1711,7 @@ function InfoTab({
1655
1711
  introContent={introContent}
1656
1712
  leadContent={leadContent}
1657
1713
  onEvaluateCapacity={onEvaluateCapacity}
1714
+ recentImageSave={recentImageSave}
1658
1715
  />
1659
1716
  )
1660
1717
  }
@@ -1673,10 +1730,17 @@ const LOGS_TAB_WITHOUT_PODS_KINDS = new Set([
1673
1730
  'scaledjobs',
1674
1731
  ])
1675
1732
  const RUNTIME_WORKLOAD_OVERVIEW_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs'])
1733
+ const ROLLOUT_STATUS_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'rollouts'])
1676
1734
  type RuntimeOverviewShape = 'replicated' | 'job' | 'cronjob'
1677
1735
 
1678
- function isRuntimeWorkloadOverviewKind(kind: string) {
1679
- return RUNTIME_WORKLOAD_OVERVIEW_KINDS.has(kindToPlural(kind).toLowerCase())
1736
+ function isRuntimeWorkloadOverviewKind(kind: string, group?: string) {
1737
+ const normalizedKind = kindToPlural(kind).toLowerCase()
1738
+ return RUNTIME_WORKLOAD_OVERVIEW_KINDS.has(normalizedKind) &&
1739
+ (normalizedKind !== 'jobs' || isCoreBatchJob(kind, group))
1740
+ }
1741
+
1742
+ function isRolloutStatusKind(kind: string) {
1743
+ return ROLLOUT_STATUS_KINDS.has(kindToPlural(kind).toLowerCase())
1680
1744
  }
1681
1745
 
1682
1746
  function runtimeOverviewShape(kind: string): RuntimeOverviewShape {
@@ -1711,6 +1775,7 @@ function WorkloadOverviewTab({
1711
1775
  introContent,
1712
1776
  leadContent,
1713
1777
  onEvaluateCapacity,
1778
+ recentImageSave,
1714
1779
  }: {
1715
1780
  resource: any
1716
1781
  selectedResource: SelectedResource
@@ -1736,8 +1801,9 @@ function WorkloadOverviewTab({
1736
1801
  extraContent?: ReactNode
1737
1802
  introContent?: ReactNode
1738
1803
  leadContent?: ReactNode
1804
+ recentImageSave?: boolean
1739
1805
  }) {
1740
- const apiKind = kindToPlural(selectedResource.kind)
1806
+ const apiKind = kindToPluralWithGroup(selectedResource.kind, selectedResource.group ?? '')
1741
1807
  const resourceKind = resource?.kind || displayKindName(apiKind, resource?.kind)
1742
1808
  const readiness = getReadinessSummary(resource, apiKind)
1743
1809
  const podSummary = getPodSummary(workloadPods, relationships, apiKind)
@@ -1758,7 +1824,7 @@ function WorkloadOverviewTab({
1758
1824
  : 'Related resources'
1759
1825
 
1760
1826
  if (overviewShape === 'replicated') {
1761
- const state = getReplicatedWorkloadState(resource, apiKind)
1827
+ const state = getReplicatedWorkloadState(resource, apiKind, workloadPods)
1762
1828
  const serviceAccountName = getPodTemplateSpec(resource, apiKind)?.serviceAccountName || 'default'
1763
1829
  return (
1764
1830
  <div className="h-full overflow-auto bg-theme-base">
@@ -1771,6 +1837,7 @@ function WorkloadOverviewTab({
1771
1837
  readiness={readiness}
1772
1838
  podSummary={podSummary}
1773
1839
  servingSummary={showServingPath ? buildServingStripSummary(servingRelationshipGroups, servingResources) : undefined}
1840
+ recentImageSave={recentImageSave}
1774
1841
  />
1775
1842
 
1776
1843
  {onEvaluateCapacity && (
@@ -1989,9 +2056,10 @@ interface ReplicatedWorkloadState {
1989
2056
  label: string
1990
2057
  detail: string
1991
2058
  badgeClass: string
1992
- level: 'healthy' | 'neutral' | 'degraded' | 'unhealthy' | 'unknown'
2059
+ level: 'healthy' | 'neutral' | 'degraded' | 'alert' | 'unhealthy' | 'unknown'
1993
2060
  healthy: boolean
1994
2061
  needsAttention: boolean
2062
+ rollout: WorkloadRolloutActivity
1995
2063
  }
1996
2064
 
1997
2065
  function WorkloadStatusStrip({
@@ -1999,14 +2067,21 @@ function WorkloadStatusStrip({
1999
2067
  readiness,
2000
2068
  podSummary,
2001
2069
  servingSummary,
2070
+ recentImageSave,
2002
2071
  }: {
2003
2072
  state: ReplicatedWorkloadState
2004
2073
  readiness: ReadinessSummary
2005
2074
  podSummary: ReturnType<typeof getPodSummary>
2006
2075
  servingSummary?: string
2076
+ recentImageSave?: boolean
2007
2077
  }) {
2008
2078
  const servingMode = Boolean(servingSummary)
2009
- const stateDetail = servingMode && state.healthy ? 'runtime health' : state.detail
2079
+ const baseStateDetail = servingMode && state.healthy ? 'runtime health' : state.detail
2080
+ const stateDetail = recentImageSave
2081
+ ? state.rollout.phase === 'idle'
2082
+ ? 'Image saved · waiting for controller status'
2083
+ : `Image saved · ${baseStateDetail}`
2084
+ : baseStateDetail
2010
2085
  const podItem = servingMode && typeof podSummary.total === 'number'
2011
2086
  ? {
2012
2087
  label: 'Pods',
@@ -2040,50 +2115,19 @@ function WorkloadStatusStrip({
2040
2115
  )
2041
2116
  }
2042
2117
 
2043
- function getReplicatedWorkloadState(resource: any, apiKind: string): ReplicatedWorkloadState {
2044
- const status = resource?.status || {}
2045
- const spec = resource?.spec || {}
2046
- const metadata = resource?.metadata || {}
2047
- const k = apiKind.toLowerCase()
2048
- const generation = metadata.generation
2049
- const observedGeneration = status.observedGeneration
2050
- const controllerBehind =
2051
- typeof generation === 'number' &&
2052
- typeof observedGeneration === 'number' &&
2053
- observedGeneration < generation
2054
-
2055
- if (k === 'daemonsets') {
2056
- const desired = status.desiredNumberScheduled ?? 0
2057
- const ready = status.numberReady ?? 0
2058
- const updated = status.updatedNumberScheduled ?? 0
2059
- const unavailable = status.numberUnavailable ?? Math.max(0, desired - ready)
2060
- if (desired === 0) return stateSummary('No targets', 'selector matches no nodes', 'status-neutral', 'neutral')
2061
- if (controllerBehind) return stateSummary('Applying', 'controller has not observed latest spec', 'status-neutral', 'neutral')
2062
- if (updated < desired) return stateSummary('Rolling out', `${updated}/${desired} updated`, 'status-neutral', 'neutral')
2063
- if (ready === desired && unavailable === 0) return stateSummary('Healthy', `${ready}/${desired} ready`, 'status-healthy', 'healthy')
2064
- if (ready > 0) return stateSummary('Degraded', `${ready}/${desired} ready`, 'status-degraded', 'degraded')
2065
- return stateSummary('Unhealthy', 'no scheduled pods are ready', 'status-unhealthy', 'unhealthy')
2118
+ function getReplicatedWorkloadState(resource: any, apiKind: string, workloadPods?: WorkloadPodInfo[]): ReplicatedWorkloadState {
2119
+ const { activity: rollout, status } = getWorkloadDisplayStatus(resource, apiKind, workloadPods)
2120
+ if (rollout.phase !== 'idle' || rollout.label !== 'Stable') {
2121
+ return stateSummary(rollout.label, rollout.detail || '', status.color, status.level, rollout)
2066
2122
  }
2067
-
2068
- const desired = spec.replicas ?? status.replicas ?? 0
2069
- const ready = status.readyReplicas ?? 0
2070
- const updated = status.updatedReplicas ?? 0
2071
- const available = status.availableReplicas ?? ready
2072
- const unavailable = status.unavailableReplicas ?? Math.max(0, desired - available)
2073
- if (desired === 0) return stateSummary('Scaled to zero', 'no replicas requested', 'status-neutral', 'neutral')
2074
- if (k === 'deployments' && spec.paused) return stateSummary('Paused', `${ready}/${desired} ready`, 'status-neutral', 'neutral')
2075
- const progressCondition = Array.isArray(status.conditions)
2076
- ? status.conditions.find((condition: any) => condition.type === 'Progressing')
2077
- : undefined
2078
- if (progressCondition?.status === 'False' || progressCondition?.reason === 'ProgressDeadlineExceeded') {
2079
- return stateSummary('Stalled', progressCondition.message || `${ready}/${desired} ready`, 'status-unhealthy', 'unhealthy')
2080
- }
2081
- if (controllerBehind) return stateSummary('Applying', 'controller has not observed latest spec', 'status-neutral', 'neutral')
2082
- const statefulSetRolling = k === 'statefulsets' && status.updateRevision && status.currentRevision && status.updateRevision !== status.currentRevision
2083
- if (updated < desired || statefulSetRolling) return stateSummary('Rolling out', `${updated}/${desired} updated`, 'status-neutral', 'neutral')
2084
- if (ready === desired && available === desired && unavailable === 0) return stateSummary('Healthy', `${ready}/${desired} ready`, 'status-healthy', 'healthy')
2085
- if (ready > 0) return stateSummary('Degraded', `${ready}/${desired} ready`, 'status-degraded', 'degraded')
2086
- return stateSummary('Unhealthy', 'no replicas are ready', 'status-unhealthy', 'unhealthy')
2123
+ const label = status.level === 'healthy'
2124
+ ? 'Healthy'
2125
+ : status.level === 'degraded'
2126
+ ? 'Degraded'
2127
+ : status.level === 'unhealthy'
2128
+ ? 'Unhealthy'
2129
+ : 'Stable'
2130
+ return stateSummary(label, rollout.detail || status.text, status.color, status.level, rollout)
2087
2131
  }
2088
2132
 
2089
2133
  function stateSummary(
@@ -2091,6 +2135,7 @@ function stateSummary(
2091
2135
  detail: string,
2092
2136
  badgeClass: string,
2093
2137
  level: ReplicatedWorkloadState['level'],
2138
+ rollout: WorkloadRolloutActivity,
2094
2139
  ): ReplicatedWorkloadState {
2095
2140
  return {
2096
2141
  label,
@@ -2098,7 +2143,8 @@ function stateSummary(
2098
2143
  badgeClass,
2099
2144
  level,
2100
2145
  healthy: level === 'healthy',
2101
- needsAttention: level === 'degraded' || level === 'unhealthy',
2146
+ needsAttention: level === 'degraded' || level === 'alert' || level === 'unhealthy',
2147
+ rollout,
2102
2148
  }
2103
2149
  }
2104
2150
 
@@ -2247,16 +2293,16 @@ function getReadinessSummary(resource: any, apiKind: string): ReadinessSummary {
2247
2293
  const available = status.numberAvailable ?? ready
2248
2294
  const unavailable = status.numberUnavailable ?? Math.max(0, desired - available)
2249
2295
  const noTargets = desired === 0
2250
- const healthy = !noTargets && ready === desired && unavailable === 0
2296
+ const healthy = !noTargets && ready >= desired && unavailable === 0
2251
2297
  return {
2252
- readyLabel: `${ready}/${desired}`,
2298
+ readyLabel: `${Math.min(ready, desired)}/${desired}`,
2253
2299
  detail: `${updated}/${desired} up-to-date`,
2254
2300
  healthLabel: noTargets ? 'No targets' : healthy ? 'Healthy' : 'Degraded',
2255
2301
  healthBadge: noTargets ? 'bg-theme-elevated text-theme-text-secondary' : healthy ? 'status-healthy' : 'status-degraded',
2256
2302
  hasIssue: !healthy && !noTargets,
2257
2303
  stats: [
2258
2304
  { label: 'Desired', value: desired },
2259
- { label: 'Ready', value: ready, tone: ready === desired ? 'good' : 'warn' },
2305
+ { label: 'Ready', value: Math.min(ready, desired), tone: ready >= desired ? 'good' : 'warn' },
2260
2306
  { label: 'Available', value: available },
2261
2307
  { label: 'Unavailable', value: unavailable, tone: unavailable > 0 ? 'warn' : 'default' },
2262
2308
  ],
@@ -2307,16 +2353,17 @@ function getReadinessSummary(resource: any, apiKind: string): ReadinessSummary {
2307
2353
  }
2308
2354
  }
2309
2355
 
2310
- const desired = spec.replicas ?? status.replicas ?? 0
2356
+ const desired = spec.replicas ?? 1
2311
2357
  const current = status.replicas ?? desired
2312
2358
  const ready = status.readyReplicas ?? 0
2359
+ const targetReady = Math.min(ready, desired)
2313
2360
  const updated = status.updatedReplicas ?? 0
2314
2361
  const available = status.availableReplicas ?? ready
2315
2362
  const unavailable = status.unavailableReplicas ?? Math.max(0, desired - available)
2316
2363
  const scaledToZero = desired === 0
2317
- const healthy = !scaledToZero && ready === desired && unavailable === 0
2364
+ const healthy = !scaledToZero && ready >= desired && unavailable === 0
2318
2365
  return {
2319
- readyLabel: `${ready}/${desired}`,
2366
+ readyLabel: `${targetReady}/${desired}`,
2320
2367
  detail: `${updated}/${desired} updated · ${available} available`,
2321
2368
  healthLabel: scaledToZero ? 'Scaled to zero' : healthy ? 'Healthy' : 'Degraded',
2322
2369
  healthBadge: scaledToZero ? 'bg-theme-elevated text-theme-text-secondary' : healthy ? 'status-healthy' : 'status-degraded',
@@ -2324,7 +2371,7 @@ function getReadinessSummary(resource: any, apiKind: string): ReadinessSummary {
2324
2371
  stats: [
2325
2372
  { label: 'Desired', value: desired },
2326
2373
  { label: 'Current', value: current },
2327
- { label: 'Ready', value: ready, tone: ready === desired ? 'good' : 'warn' },
2374
+ { label: 'Ready', value: targetReady, tone: ready >= desired ? 'good' : 'warn' },
2328
2375
  { label: 'Updated', value: updated },
2329
2376
  { label: 'Available', value: available },
2330
2377
  { label: 'Unavailable', value: unavailable, tone: unavailable > 0 ? 'warn' : 'default' },
@@ -2455,7 +2502,7 @@ function CurrentStateCard({ resource, apiKind, readiness }: { resource: any; api
2455
2502
  const metadata = resource?.metadata || {}
2456
2503
  const templateSpec = getPodTemplateSpec(resource, apiKind)
2457
2504
  const containers = templateSpec?.containers ?? []
2458
- const image = containers[0]?.image
2505
+ const images = containers.filter((container: any) => container?.name && container?.image)
2459
2506
  const k = apiKind.toLowerCase()
2460
2507
  const title = k === 'jobs' ? 'Execution state' : k === 'cronjobs' ? 'Schedule state' : 'Current state'
2461
2508
  const generation = metadata.generation
@@ -2482,7 +2529,21 @@ function CurrentStateCard({ resource, apiKind, readiness }: { resource: any; api
2482
2529
  <div className="mt-4 grid gap-2 border-t border-theme-border pt-3 text-sm sm:grid-cols-2">
2483
2530
  <InlineFact label="Kind" value={displayKindName(apiKind, resource?.kind)} />
2484
2531
  {templateSpec && <InlineFact label="Service account" value={templateSpec.serviceAccountName || 'default'} />}
2485
- {image && <InlineFact label="Image" value={<span className="font-mono text-xs">{image}</span>} />}
2532
+ {images.length === 1 && <InlineFact label="Image" value={<span className="break-all font-mono text-xs">{images[0].image}</span>} />}
2533
+ {images.length > 1 && (
2534
+ <InlineFact
2535
+ label="Images"
2536
+ value={(
2537
+ <span className="flex min-w-0 flex-col gap-1">
2538
+ {images.map((container: any) => (
2539
+ <span key={container.name} className="min-w-0 break-all font-mono text-xs">
2540
+ <span className="text-theme-text-tertiary">{container.name}:</span> {container.image}
2541
+ </span>
2542
+ ))}
2543
+ </span>
2544
+ )}
2545
+ />
2546
+ )}
2486
2547
  </div>
2487
2548
  </OverviewCard>
2488
2549
  )
@@ -2580,7 +2641,9 @@ function RuntimeCard({
2580
2641
  onSwitchToLogs?: () => void
2581
2642
  }) {
2582
2643
  const progress = rolloutProgress(resource, apiKind, state)
2583
- const headline = rolloutHeadline(resource, apiKind, readiness, scaling)
2644
+ const headline = state.rollout.phase !== 'idle'
2645
+ ? `${state.label}${state.detail ? ` · ${state.detail}` : ''}`
2646
+ : rolloutHeadline(resource, apiKind, readiness, scaling)
2584
2647
  const facts = rolloutFacts(resource, apiKind, scaling, Boolean(progress))
2585
2648
  const scalerRefs = relationships?.scalers ?? []
2586
2649
  const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
@@ -3311,7 +3374,7 @@ function RolloutProgressBar({ progress, state }: { progress: RolloutProgressSumm
3311
3374
 
3312
3375
  function rolloutProgressFillClass(level: ReplicatedWorkloadState['level']): string {
3313
3376
  if (level === 'unhealthy') return 'bg-red-500'
3314
- if (level === 'degraded') return 'bg-amber-500'
3377
+ if (level === 'degraded' || level === 'alert') return 'bg-amber-500'
3315
3378
  return 'bg-accent'
3316
3379
  }
3317
3380
 
@@ -3333,7 +3396,7 @@ function RolloutFactChip({ label, value, tone = 'default' }: { label: string; va
3333
3396
 
3334
3397
  function rolloutStateDotClass(level: ReplicatedWorkloadState['level']): string {
3335
3398
  if (level === 'unhealthy') return 'bg-red-500'
3336
- if (level === 'degraded') return 'bg-amber-500'
3399
+ if (level === 'degraded' || level === 'alert') return 'bg-amber-500'
3337
3400
  if (level === 'neutral') return 'bg-skyhook-500'
3338
3401
  if (level === 'unknown') return 'bg-theme-text-tertiary'
3339
3402
  return 'bg-emerald-500'
@@ -3373,7 +3436,7 @@ function rolloutFacts(
3373
3436
  return facts
3374
3437
  }
3375
3438
 
3376
- const desired = spec.replicas ?? status.replicas ?? 0
3439
+ const desired = spec.replicas ?? 1
3377
3440
  const current = status.replicas ?? desired
3378
3441
  const updated = status.updatedReplicas ?? 0
3379
3442
  const unavailable = status.unavailableReplicas ?? Math.max(0, desired - (status.availableReplicas ?? status.readyReplicas ?? 0))
@@ -3407,13 +3470,14 @@ function rolloutProgress(resource: any, apiKind: string, state: ReplicatedWorklo
3407
3470
  const spec = resource?.spec || {}
3408
3471
  const status = resource?.status || {}
3409
3472
  const k = apiKind.toLowerCase()
3473
+ if (state.rollout.phase === 'idle' || state.rollout.phase === 'partition-reached' || state.rollout.phase === 'applying') return null
3410
3474
 
3411
3475
  if (k === 'daemonsets') {
3412
3476
  const desired = status.desiredNumberScheduled ?? 0
3413
3477
  const updated = status.updatedNumberScheduled ?? 0
3414
3478
  const ready = status.numberReady ?? 0
3415
3479
  const unavailable = status.numberUnavailable ?? Math.max(0, desired - ready)
3416
- if (!shouldShowRolloutProgress(desired, updated, ready, unavailable, state)) return null
3480
+ if (!shouldShowRolloutProgress(desired, state)) return null
3417
3481
  return {
3418
3482
  updated,
3419
3483
  desired,
@@ -3424,31 +3488,30 @@ function rolloutProgress(resource: any, apiKind: string, state: ReplicatedWorklo
3424
3488
  }
3425
3489
  }
3426
3490
 
3427
- const desired = spec.replicas ?? status.replicas ?? 0
3491
+ const configuredDesired = spec.replicas ?? 1
3492
+ const desired = k === 'statefulsets'
3493
+ ? Math.max(0, configuredDesired - (spec.updateStrategy?.rollingUpdate?.partition ?? 0))
3494
+ : configuredDesired
3428
3495
  const updated = status.updatedReplicas ?? 0
3429
3496
  const ready = status.readyReplicas ?? 0
3430
- const unavailable = status.unavailableReplicas ?? Math.max(0, desired - (status.availableReplicas ?? ready))
3431
- if (!shouldShowRolloutProgress(desired, updated, ready, unavailable, state)) return null
3497
+ const unavailable = status.unavailableReplicas ?? Math.max(0, configuredDesired - (status.availableReplicas ?? ready))
3498
+ if (!shouldShowRolloutProgress(desired, state)) return null
3432
3499
  return {
3433
3500
  updated,
3434
3501
  desired,
3435
3502
  ready,
3436
3503
  unavailable,
3437
3504
  updatedLabel: `${updated}/${desired} updated`,
3438
- readyLabel: `${ready}/${desired} ready`,
3505
+ readyLabel: `${Math.min(ready, configuredDesired)}/${configuredDesired} ready`,
3439
3506
  }
3440
3507
  }
3441
3508
 
3442
3509
  function shouldShowRolloutProgress(
3443
3510
  desired: number,
3444
- updated: number,
3445
- ready: number,
3446
- unavailable: number,
3447
3511
  state: ReplicatedWorkloadState,
3448
3512
  ): boolean {
3449
3513
  if (desired <= 0) return false
3450
- if (updated < desired || ready < desired || unavailable > 0) return true
3451
- return state.label === 'Rolling out' || state.label === 'Applying' || state.label === 'Stalled'
3514
+ return state.rollout.active || state.rollout.phase === 'stalled'
3452
3515
  }
3453
3516
 
3454
3517
  function rolloutHeadline(resource: any, apiKind: string, readiness: ReadinessSummary, scaling: ScalingSummary): string {
@@ -0,0 +1,37 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ const FAST_REFRESH_INTERVAL = 2000
4
+ const SLOW_REFRESH_INTERVAL = 15000
5
+ const FAST_REFRESH_DURATION = 120000
6
+
7
+ export function useProgressiveRefresh(
8
+ enabled: boolean,
9
+ refresh?: () => void | Promise<unknown>,
10
+ ) {
11
+ const refreshRef = useRef(refresh)
12
+ refreshRef.current = refresh
13
+ const hasRefresh = Boolean(refresh)
14
+
15
+ useEffect(() => {
16
+ if (!enabled || !hasRefresh) return
17
+ const startedAt = Date.now()
18
+ let cancelled = false
19
+ let timeout: number | undefined
20
+ const schedule = () => {
21
+ const delay = Date.now() - startedAt < FAST_REFRESH_DURATION ? FAST_REFRESH_INTERVAL : SLOW_REFRESH_INTERVAL
22
+ timeout = window.setTimeout(() => {
23
+ void Promise.resolve()
24
+ .then(() => refreshRef.current?.())
25
+ .catch(() => undefined)
26
+ .finally(() => {
27
+ if (!cancelled) schedule()
28
+ })
29
+ }, delay)
30
+ }
31
+ schedule()
32
+ return () => {
33
+ cancelled = true
34
+ if (timeout !== undefined) window.clearTimeout(timeout)
35
+ }
36
+ }, [enabled, hasRefresh])
37
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readdirSync, readFileSync, statSync } from 'fs'
3
+ import { join } from 'path'
4
+
5
+ // A stray NUL in a source file compiles fine and passes every type and unit
6
+ // check, so nothing else here would catch one. What it does break is the
7
+ // tooling around the file: grep treats the file as binary and silently reports
8
+ // no matches, which is a genuinely confusing way to lose an afternoon. This
9
+ // package is also published as source, so the byte would ship to consumers.
10
+ function sourceFiles(dir: string): string[] {
11
+ const out: string[] = []
12
+ for (const entry of readdirSync(dir)) {
13
+ if (entry === 'node_modules' || entry === 'dist') continue
14
+ const full = join(dir, entry)
15
+ if (statSync(full).isDirectory()) out.push(...sourceFiles(full))
16
+ else if (/\.(ts|tsx|css)$/.test(entry)) out.push(full)
17
+ }
18
+ return out
19
+ }
20
+
21
+ describe('source hygiene', () => {
22
+ it('has no NUL bytes in any source file', () => {
23
+ const offenders = sourceFiles(join(__dirname))
24
+ .filter(f => readFileSync(f).includes(0x00))
25
+ .map(f => f.slice(f.indexOf('/src/')))
26
+ expect(offenders).toEqual([])
27
+ })
28
+ })
@@ -22,6 +22,10 @@
22
22
  --color-theme-text-disabled: var(--text-disabled);
23
23
  --color-warning-text: var(--warning-text);
24
24
 
25
+ /* Semantic status */
26
+ --color-semantic-warning: var(--color-warning);
27
+ --color-semantic-error: var(--color-error);
28
+
25
29
  /* Borders */
26
30
  --color-theme-border: var(--border-default);
27
31
  --color-theme-border-light: var(--border-light);