@skyhook-io/k8s-ui 1.14.9 → 1.14.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/package.json +3 -2
  2. package/src/components/applications/ApplicationDetail.tsx +24 -11
  3. package/src/components/audit/AuditAlerts.tsx +1 -3
  4. package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
  5. package/src/components/checks/ChecksView.test.tsx +55 -0
  6. package/src/components/checks/ChecksView.tsx +38 -27
  7. package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
  8. package/src/components/dock/NodeTerminalTab.tsx +51 -47
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
  10. package/src/components/gitops/detail-helpers.test.ts +32 -0
  11. package/src/components/gitops/detail-helpers.ts +21 -0
  12. package/src/components/gitops/health-provenance.test.ts +4 -3
  13. package/src/components/gitops/index.ts +2 -1
  14. package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
  15. package/src/components/issues/IssuesView.tsx +6 -0
  16. package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
  17. package/src/components/issues/diagnostic.ts +4 -0
  18. package/src/components/logs/LogCore.tsx +3 -3
  19. package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
  20. package/src/components/logs/useLogBuffer.ts +1 -0
  21. package/src/components/logs/useLogStream.ts +1 -2
  22. package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
  23. package/src/components/resources/KueueAdmissionSection.tsx +87 -0
  24. package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
  25. package/src/components/resources/ResourcesView.tsx +91 -16
  26. package/src/components/resources/curated-column-ownership.test.ts +45 -0
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
  28. package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
  29. package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
  30. package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
  31. package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
  32. package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
  33. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
  34. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
  35. package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
  36. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
  37. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
  38. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
  39. package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
  40. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
  41. package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
  42. package/src/components/resources/renderers/index.ts +11 -0
  43. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
  44. package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
  45. package/src/components/resources/resource-utils-kueue.ts +145 -50
  46. package/src/components/resources/resource-utils-ray.test.ts +58 -1
  47. package/src/components/resources/resource-utils-ray.ts +56 -90
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
  49. package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
  50. package/src/components/timeline/TimelineList.tsx +5 -0
  51. package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
  52. package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
  53. package/src/components/topology/K8sResourceNode.tsx +3 -3
  54. package/src/components/ui/CodeViewer.tsx +4 -0
  55. package/src/components/ui/Toast.tsx +9 -3
  56. package/src/components/ui/drawer-components.tsx +3 -1
  57. package/src/components/workload/PodList.tsx +154 -0
  58. package/src/components/workload/WorkloadView.tsx +29 -159
  59. package/src/components/workload/index.ts +11 -1
  60. package/src/components/workload/workload-logs-availability.test.ts +20 -0
  61. package/src/components/workload/workload-pod-presentation.test.ts +27 -0
  62. package/src/types/core.ts +24 -0
  63. package/src/types/gitops-tree.ts +5 -2
  64. package/src/types/scheduling.ts +71 -0
  65. package/src/utils/api-resources.test.ts +20 -1
  66. package/src/utils/api-resources.ts +26 -0
  67. package/src/utils/application-topology.test.ts +25 -0
  68. package/src/utils/application-topology.ts +2 -1
  69. package/src/utils/applications.test.ts +82 -0
  70. package/src/utils/applications.ts +62 -24
  71. package/src/utils/gitops-route.test.ts +20 -1
  72. package/src/utils/gitops-route.ts +15 -7
  73. package/src/utils/log-format.ts +5 -2
  74. package/src/utils/log-stream-error.test.ts +13 -0
  75. package/src/utils/navigation.test.ts +2 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/resource-hierarchy.test.ts +402 -15
  78. package/src/utils/resource-hierarchy.ts +263 -178
  79. package/src/utils/topology-neighborhood.test.ts +72 -1
  80. package/src/utils/topology-neighborhood.ts +40 -18
@@ -1,3 +1,5 @@
1
+ import { PodRow, PodListFrame, PodListToggle, workloadPodDetail } from './PodList'
2
+ export { workloadPodDetail, podStatusLabel, podStatusClass, podDotClass } from './PodList'
1
3
  import { useState, useMemo, useEffect, useRef, useCallback, type ComponentType, type ReactNode } from 'react'
2
4
  import { flushSync } from 'react-dom'
3
5
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
@@ -7,7 +9,6 @@ import { FetchResult } from '../ui/FetchResult'
7
9
  import { PaneLoader } from '../ui/PaneLoader'
8
10
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
9
11
  import { clsx } from 'clsx'
10
- import { Collapse } from '../ui/Collapse'
11
12
  import {
12
13
  ArrowLeft,
13
14
  ArrowRight,
@@ -38,7 +39,7 @@ import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, Resol
38
39
  import type { GitOpsStatus } from '../../types/gitops'
39
40
  import type { NavigateToResource } from '../../utils/navigation'
40
41
  import { refToSelectedResource, pluralToKind, knownKindForPluralWithGroup, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
41
- import { neighborhoodFor, seedNodeIds } from '../../utils/topology-neighborhood'
42
+ import { neighborhoodFor, seedNodeIds, topologyNodeResourceKind } from '../../utils/topology-neighborhood'
42
43
  import { TopologyGraph } from '../topology/TopologyGraph'
43
44
  import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
44
45
  import { gitOpsRouteForResource } from '../../utils/gitops-route'
@@ -441,8 +442,9 @@ export function WorkloadView({
441
442
  onOpenHelmRelease,
442
443
  onNavigateGitOpsPath,
443
444
  }: WorkloadViewProps) {
444
- // Normalize kind: URL has plural lowercase, internal logic uses singular PascalCase
445
- const kind = pluralToKind(kindProp)
445
+ // The live object preserves exact CRD capitalization (RayJob, TFJob); the URL
446
+ // plural remains the API resource name used by fetches and mutations.
447
+ const kind = resource?.kind || pluralToKind(kindProp)
446
448
  const apiKind = kindProp
447
449
 
448
450
  // Tab state — controlled or uncontrolled
@@ -498,14 +500,14 @@ export function WorkloadView({
498
500
  return buildResourceHierarchy({
499
501
  events: allEvents,
500
502
  topology,
501
- rootResource: { kind, namespace, name },
503
+ rootResource: { kind, group, namespace, name },
502
504
  groupByApp: true,
503
505
  })
504
- }, [allEvents, topology, kind, namespace, name])
506
+ }, [allEvents, topology, kind, group, namespace, name])
505
507
 
506
508
  // Topology tab — the seeded neighborhood around this one workload (its
507
509
  // ownership core + attached Services/config/policies), not the whole namespace.
508
- const neighborhoodSeed = useMemo(() => [{ kind, namespace, name }], [kind, namespace, name])
510
+ const neighborhoodSeed = useMemo(() => [{ kind, group, namespace, name }], [kind, group, namespace, name])
509
511
  const neighborhood = useMemo(
510
512
  () => (topology ? neighborhoodFor(topology, neighborhoodSeed) : null),
511
513
  [topology, neighborhoodSeed],
@@ -538,7 +540,7 @@ export function WorkloadView({
538
540
  .filter((n) => n.kind !== 'Internet' && n.kind !== 'PodGroup')
539
541
  .map((n) => ({
540
542
  id: n.id,
541
- kind: n.kind as string,
543
+ kind: topologyNodeResourceKind(n),
542
544
  namespace: (n.data?.namespace as string) || namespace,
543
545
  name: n.name,
544
546
  group: apiVersionToGroup(n.data?.apiVersion as string | undefined),
@@ -556,10 +558,11 @@ export function WorkloadView({
556
558
  const yamlObject = yamlObjectId ? yamlObjects.find((o) => o.id === yamlObjectId) : undefined
557
559
  const handleTopologyNodeClick = useCallback(
558
560
  (node: TopologyNode) => {
559
- if (!onNavigateToResource || !node.kind || !node.name) return
561
+ const resourceKind = topologyNodeResourceKind(node)
562
+ if (!onNavigateToResource || !resourceKind || !node.name) return
560
563
  const group = apiVersionToGroup(node.data?.apiVersion as string | undefined)
561
564
  onNavigateToResource({
562
- kind: kindToPluralWithGroup(node.kind, group),
565
+ kind: kindToPluralWithGroup(resourceKind, group),
563
566
  namespace: (node.data?.namespace as string) || '',
564
567
  name: node.name,
565
568
  group,
@@ -742,8 +745,7 @@ export function WorkloadView({
742
745
  const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false
743
746
  const showCostTab = isCostAvailable ? isCostAvailable(kind, resource) : false
744
747
  const normalizedKind = kindToPlural(kind).toLowerCase()
745
- const logsWithoutPods = LOGS_TAB_WITHOUT_PODS_KINDS.has(normalizedKind) &&
746
- (normalizedKind !== 'jobs' || isCoreBatchJob(kind, group))
748
+ const logsWithoutPods = supportsLogsWithoutPods(normalizedKind, kind, group, resource?.apiVersion)
747
749
  const logsTabVisible = Boolean(renderLogsTab) && (allPods.length > 0 || logsWithoutPods)
748
750
  const metricsTabVisible = Boolean(showMetricsTab && renderMetricsTab)
749
751
  const costTabVisible = Boolean(showCostTab && renderCostTab)
@@ -1737,11 +1739,26 @@ const LOGS_TAB_WITHOUT_PODS_KINDS = new Set([
1737
1739
  'workflowtemplates',
1738
1740
  'clusterworkflowtemplates',
1739
1741
  'scaledjobs',
1742
+ 'jobsets',
1740
1743
  ])
1741
1744
  const RUNTIME_WORKLOAD_OVERVIEW_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs'])
1742
1745
  const ROLLOUT_STATUS_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'rollouts'])
1743
1746
  type RuntimeOverviewShape = 'replicated' | 'job' | 'cronjob'
1744
1747
 
1748
+ export function supportsLogsWithoutPods(
1749
+ normalizedKind: string,
1750
+ kind: string,
1751
+ group?: string,
1752
+ apiVersion?: string,
1753
+ ): boolean {
1754
+ if (!LOGS_TAB_WITHOUT_PODS_KINDS.has(normalizedKind)) return false
1755
+ if (normalizedKind === 'jobs') return isCoreBatchJob(kind, group)
1756
+ if (normalizedKind === 'jobsets') {
1757
+ return group === 'jobset.x-k8s.io' && apiVersion === 'jobset.x-k8s.io/v1alpha2'
1758
+ }
1759
+ return true
1760
+ }
1761
+
1745
1762
  function isRuntimeWorkloadOverviewKind(kind: string, group?: string) {
1746
1763
  const normalizedKind = kindToPlural(kind).toLowerCase()
1747
1764
  return RUNTIME_WORKLOAD_OVERVIEW_KINDS.has(normalizedKind) &&
@@ -2882,116 +2899,6 @@ function PodsListContent({
2882
2899
  )
2883
2900
  }
2884
2901
 
2885
- function PodRow({
2886
- name,
2887
- namespace,
2888
- ready,
2889
- healthLevel,
2890
- detail,
2891
- onNavigate,
2892
- }: {
2893
- name: string
2894
- namespace: string
2895
- ready: boolean | null
2896
- healthLevel?: WorkloadPodInfo['healthLevel']
2897
- detail: string
2898
- onNavigate?: NavigateToResource
2899
- }) {
2900
- const statusLabel = podStatusLabel(healthLevel, ready)
2901
- const statusClass = podStatusClass(healthLevel, ready)
2902
- const content = (
2903
- <>
2904
- <span className={clsx('h-2 w-2 shrink-0 rounded-full', podDotClass(healthLevel, ready))} />
2905
- <span className="min-w-0 flex-1">
2906
- <span className="block truncate text-sm font-medium text-theme-text-primary">{name}</span>
2907
- <span className="block truncate text-xs text-theme-text-tertiary">{detail}</span>
2908
- </span>
2909
- <span className={clsx('badge-sm shrink-0', statusClass)}>
2910
- {statusLabel}
2911
- </span>
2912
- </>
2913
- )
2914
-
2915
- if (!onNavigate) {
2916
- return (
2917
- <div className="flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left">
2918
- {content}
2919
- </div>
2920
- )
2921
- }
2922
-
2923
- return (
2924
- <button
2925
- type="button"
2926
- onClick={() => onNavigate({ kind: 'pods', namespace, name })}
2927
- className="flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left transition-colors hover:bg-theme-hover"
2928
- >
2929
- {content}
2930
- </button>
2931
- )
2932
- }
2933
-
2934
- function PodListFrame({
2935
- expanded,
2936
- hasOverflow,
2937
- overflow,
2938
- toggle,
2939
- children,
2940
- }: {
2941
- expanded: boolean
2942
- hasOverflow: boolean
2943
- overflow?: ReactNode
2944
- toggle?: ReactNode
2945
- children: ReactNode
2946
- }) {
2947
- return (
2948
- <div className="space-y-2">
2949
- <div className="space-y-2">
2950
- {children}
2951
- </div>
2952
- {hasOverflow && (
2953
- <Collapse open={expanded}>
2954
- {/* Long overflow lists scroll inside a capped box once open; the
2955
- cap comes off while closed so the collapse measures to zero. */}
2956
- <div className={clsx(expanded && 'max-h-[26rem] overflow-y-auto pr-1')}>
2957
- <div className="space-y-2">
2958
- {overflow}
2959
- </div>
2960
- </div>
2961
- </Collapse>
2962
- )}
2963
- {toggle}
2964
- </div>
2965
- )
2966
- }
2967
-
2968
- function PodListToggle({
2969
- expanded,
2970
- hiddenCount,
2971
- label,
2972
- onToggle,
2973
- }: {
2974
- expanded: boolean
2975
- hiddenCount: number
2976
- label: string
2977
- onToggle: () => void
2978
- }) {
2979
- if (!expanded && hiddenCount <= 0) return null
2980
-
2981
- return (
2982
- <div className="pt-1">
2983
- <button
2984
- type="button"
2985
- aria-expanded={expanded}
2986
- onClick={onToggle}
2987
- className="text-xs font-medium text-accent-text hover:underline"
2988
- >
2989
- {expanded ? `Collapse ${label}` : `Show ${hiddenCount} more ${label}`}
2990
- </button>
2991
- </div>
2992
- )
2993
- }
2994
-
2995
2902
  function compareWorkloadPods(a: WorkloadPodInfo, b: WorkloadPodInfo): number {
2996
2903
  const severity = podSeverityRank(b) - podSeverityRank(a)
2997
2904
  if (severity !== 0) return severity
@@ -3011,43 +2918,6 @@ function podSeverityRank(pod: WorkloadPodInfo): number {
3011
2918
  }
3012
2919
  }
3013
2920
 
3014
- function workloadPodDetail(pod: WorkloadPodInfo): string {
3015
- const parts: string[] = []
3016
- if (pod.phase) parts.push(pod.reason ? `${pod.phase} / ${pod.reason}` : pod.phase)
3017
- else if (pod.reason) parts.push(pod.reason)
3018
- parts.push(`${pod.containers.length} container${pod.containers.length === 1 ? '' : 's'}`)
3019
- if ((pod.restartCount ?? 0) > 0) parts.push(`${pod.restartCount} restart${pod.restartCount === 1 ? '' : 's'}`)
3020
- if (pod.lastTerminationReason && !['Unknown', 'Completed'].includes(pod.lastTerminationReason)) {
3021
- parts.push(`last ${pod.lastTerminationReason}`)
3022
- }
3023
- if (pod.createdAt) parts.push(`${formatAge(pod.createdAt)} old`)
3024
- return parts.join(' · ')
3025
- }
3026
-
3027
- function podStatusLabel(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
3028
- if (healthLevel === 'unhealthy') return 'Unhealthy'
3029
- if (healthLevel === 'degraded') return 'Degraded'
3030
- if (healthLevel === 'neutral') return ready ? 'Ready' : 'Neutral'
3031
- if (healthLevel === 'unknown') return 'Unknown'
3032
- return ready === null ? 'Unknown' : ready ? 'Ready' : 'Not ready'
3033
- }
3034
-
3035
- function podStatusClass(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
3036
- if (healthLevel === 'unhealthy') return 'status-unhealthy'
3037
- if (healthLevel === 'degraded') return 'status-degraded'
3038
- if (healthLevel === 'neutral') return 'status-neutral'
3039
- if (healthLevel === 'unknown' || ready === null) return 'status-unknown'
3040
- return ready ? 'status-healthy' : 'status-degraded'
3041
- }
3042
-
3043
- function podDotClass(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
3044
- if (healthLevel === 'unhealthy') return 'bg-red-500'
3045
- if (healthLevel === 'degraded') return 'bg-amber-500'
3046
- if (healthLevel === 'neutral') return 'bg-skyhook-500'
3047
- if (healthLevel === 'unknown' || ready === null) return 'bg-theme-text-tertiary'
3048
- return ready ? 'bg-emerald-500' : 'bg-amber-500'
3049
- }
3050
-
3051
2921
  function MoreRows({ count, label }: { count: number; label: string }) {
3052
2922
  return <div className="text-xs text-theme-text-tertiary">+{count} more {label} hidden from this overview.</div>
3053
2923
  }
@@ -1,2 +1,12 @@
1
- export { WorkloadView, isDiagnoseKind, type ResourceOwnershipContext, type ServingResourceDetail, type WorkloadTabType } from './WorkloadView'
1
+ export {
2
+ WorkloadView,
3
+ isDiagnoseKind,
4
+ workloadPodDetail,
5
+ podStatusLabel,
6
+ podStatusClass,
7
+ podDotClass,
8
+ type ResourceOwnershipContext,
9
+ type ServingResourceDetail,
10
+ type WorkloadTabType,
11
+ } from './WorkloadView'
2
12
  export { ResourceDetailDrawer } from './ResourceDetailDrawer'
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { supportsLogsWithoutPods } from './WorkloadView'
4
+
5
+ describe('supportsLogsWithoutPods', () => {
6
+ it('allows the supported JobSet identity to select child Job logs', () => {
7
+ expect(supportsLogsWithoutPods('jobsets', 'JobSet', 'jobset.x-k8s.io', 'jobset.x-k8s.io/v1alpha2')).toBe(true)
8
+ })
9
+
10
+ it('rejects foreign and unsupported same-kind JobSets', () => {
11
+ expect(supportsLogsWithoutPods('jobsets', 'JobSet', 'example.io', 'example.io/v1alpha2')).toBe(false)
12
+ expect(supportsLogsWithoutPods('jobsets', 'JobSet', 'jobset.x-k8s.io', 'jobset.x-k8s.io/v1beta1')).toBe(false)
13
+ })
14
+
15
+ it('preserves existing scheduled workload and core Job behavior', () => {
16
+ expect(supportsLogsWithoutPods('cronjobs', 'CronJob', 'batch', 'batch/v1')).toBe(true)
17
+ expect(supportsLogsWithoutPods('jobs', 'Job', 'batch', 'batch/v1')).toBe(true)
18
+ expect(supportsLogsWithoutPods('jobs', 'Job', 'example.io', 'example.io/v1')).toBe(false)
19
+ })
20
+ })
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ podStatusClass,
4
+ podStatusLabel,
5
+ workloadPodDetail,
6
+ } from './WorkloadView'
7
+
8
+ describe('workload Pod presentation', () => {
9
+ it('surfaces container failure evidence instead of reporting only the Pod phase', () => {
10
+ const pod = {
11
+ name: 'workers-2-abc',
12
+ phase: 'Running',
13
+ ready: false,
14
+ containers: ['worker'],
15
+ restartCount: 3,
16
+ reason: 'CrashLoopBackOff',
17
+ lastTerminationReason: 'OOMKilled',
18
+ healthLevel: 'unhealthy' as const,
19
+ }
20
+
21
+ expect(podStatusLabel(pod.healthLevel, pod.ready)).toBe('Unhealthy')
22
+ expect(podStatusClass(pod.healthLevel, pod.ready)).toBe('status-unhealthy')
23
+ expect(workloadPodDetail(pod)).toBe(
24
+ 'Running / CrashLoopBackOff · 1 container · 3 restarts · last OOMKilled',
25
+ )
26
+ })
27
+ })
package/src/types/core.ts CHANGED
@@ -116,6 +116,7 @@ export interface IntegrationCapability {
116
116
 
117
117
  // Feature capabilities based on RBAC permissions
118
118
  export interface Capabilities {
119
+ configManagement?: 'local' | 'operator' | 'cloud'
119
120
  exec: boolean // Terminal feature (pods/exec)
120
121
  localTerminal: boolean // Local terminal available (not in-cluster, not disabled)
121
122
  logs: boolean // Log viewer (pods/log)
@@ -746,6 +747,28 @@ export interface ResourceWithRelationships<T = unknown> {
746
747
  }
747
748
 
748
749
  // API Resource (from discovery endpoint)
750
+ export type DynamicObservationState =
751
+ | 'unwatched'
752
+ | 'deferred'
753
+ | 'syncing'
754
+ | 'synced'
755
+ | 'denied'
756
+ | 'unsupported'
757
+
758
+ export type DynamicObservationScope = 'cluster' | 'explicit_namespaces'
759
+
760
+ /** Initial sync and viewer-visible scope, not watch health or authorization.
761
+ * Origin/start time and projection flags are omitted; none proves freshness. */
762
+ export interface DynamicResourceObservation {
763
+ /** Time of a retained probe decision, not resource freshness. */
764
+ observedAt?: string
765
+ state: DynamicObservationState
766
+ scope?: DynamicObservationScope
767
+ namespaces?: string[]
768
+ truncated?: boolean
769
+ reasonCode?: string
770
+ }
771
+
749
772
  export interface APIResource {
750
773
  group: string
751
774
  version: string
@@ -755,6 +778,7 @@ export interface APIResource {
755
778
  isCrd: boolean
756
779
  featured?: boolean
757
780
  verbs: string[]
781
+ observation?: DynamicResourceObservation
758
782
  }
759
783
 
760
784
  // Helm release types
@@ -40,6 +40,9 @@ export interface GitOpsTreeNode {
40
40
  groupedNodeIDs?: string[]
41
41
  count?: number
42
42
  data?: Record<string, unknown>
43
+ // Lives on the destination cluster of a remote tree. This cluster's API
44
+ // would answer for a different, same-named object.
45
+ remote?: boolean
43
46
  }
44
47
 
45
48
  // 'controllerApi' is the controller's own verdict read from its API server
@@ -80,7 +83,7 @@ export interface GitOpsResourceTree {
80
83
  healthFromApi?: boolean
81
84
  // The controller's API server was asked and didn't answer usefully.
82
85
  healthApiError?: string
83
- // The Application deploys to another cluster; Radar derives nothing about
84
- // its resources from here.
86
+ // The Application (or Flux object with spec.kubeConfig) deploys to another
87
+ // cluster; Radar derives nothing about its resources from here.
85
88
  remoteDestination?: boolean
86
89
  }
@@ -0,0 +1,71 @@
1
+ export interface SchedulingRef {
2
+ kind: string
3
+ group?: string
4
+ namespace?: string
5
+ name: string
6
+ }
7
+
8
+ export interface SchedulingCondition {
9
+ type: string
10
+ status: string
11
+ reason?: string
12
+ message?: string
13
+ observedGeneration?: number
14
+ lastTransitionTime?: string
15
+ }
16
+
17
+ export type SchedulingDecision = 'satisfied' | 'unsatisfied' | 'held' | 'unknown'
18
+
19
+ // Matches the shared Go scheduling projection; admission UI consumes this subset.
20
+ export interface SchedulingObservation {
21
+ source: string
22
+ domain: string
23
+ subject: SchedulingRef
24
+ subjectGeneration?: number
25
+ decision: SchedulingDecision
26
+ primaryCondition?: SchedulingCondition
27
+ queues?: { name: string; roles: string[]; ref?: SchedulingRef }[]
28
+ gates?: {
29
+ kind: string
30
+ name: string
31
+ ref?: SchedulingRef
32
+ nativeState?: string
33
+ decision: SchedulingDecision
34
+ message?: string
35
+ lastTransitionTime?: string
36
+ requeueAfterSeconds?: number
37
+ retryCount?: number
38
+ }[]
39
+ disruptions?: SchedulingCondition[]
40
+ kueue?: {
41
+ phase: 'pending' | 'quota_reserved' | 'admitted' | 'finished'
42
+ outcome?: 'succeeded' | 'failed'
43
+ active?: boolean
44
+ podsReady?: SchedulingCondition
45
+ waitingForReplacementPods?: SchedulingCondition
46
+ requeueState?: { count?: number; requeueAt?: string }
47
+ concurrentAdmission?: { parentName: string; parentRef?: SchedulingRef }
48
+ }
49
+ }
50
+
51
+ export interface KueueAdmissionWorkload {
52
+ apiVersion: string
53
+ namespace: string
54
+ name: string
55
+ uid: string
56
+ generation: number
57
+ createdAt: string | null
58
+ deleting: boolean
59
+ ref?: SchedulingRef
60
+ projection: 'available' | 'unsupported' | 'forbidden'
61
+ scheduling?: { observations?: SchedulingObservation[] }
62
+ linksLimited: boolean
63
+ }
64
+
65
+ export interface KueueAdmissionResponse {
66
+ uid: string
67
+ installed: boolean
68
+ workloads: KueueAdmissionWorkload[]
69
+ total: number
70
+ truncated: boolean
71
+ }
@@ -1,5 +1,24 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { categorizeResources, findAPIResourceForRoute, formatGroupName, isCoreBatchJob, shortenGroupName } from './api-resources'
2
+ import { builtinGroupForKind, canonicalResourceGroup, categorizeResources, findAPIResourceForRoute, formatGroupName, isCoreBatchJob, shortenGroupName } from './api-resources'
3
+
4
+ describe('canonicalResourceGroup', () => {
5
+ it('infers built-in groups from either Kind or resource name', () => {
6
+ expect(builtinGroupForKind('Service')).toBe('')
7
+ expect(builtinGroupForKind('jobs')).toBe('batch')
8
+ expect(builtinGroupForKind('PodDisruptionBudget')).toBe('policy')
9
+ expect(canonicalResourceGroup('Deployment', undefined)).toBe('apps')
10
+ expect(canonicalResourceGroup('Job', '')).toBe('batch')
11
+ expect(canonicalResourceGroup('ResourceClaim', undefined)).toBe('resource.k8s.io')
12
+ expect(canonicalResourceGroup('ControllerRevision', undefined)).toBe('apps')
13
+ expect(canonicalResourceGroup('Endpoints', undefined)).toBe('')
14
+ })
15
+
16
+ it('never overwrites an explicit custom group', () => {
17
+ expect(canonicalResourceGroup('Job', 'batch.volcano.sh')).toBe('batch.volcano.sh')
18
+ expect(canonicalResourceGroup('Widget', undefined)).toBeUndefined()
19
+ expect(canonicalResourceGroup('Widget', '')).toBeUndefined()
20
+ })
21
+ })
3
22
 
4
23
  describe('findAPIResourceForRoute', () => {
5
24
  const resources = [
@@ -35,6 +35,9 @@ export const CORE_RESOURCES: APIResource[] = [
35
35
  { group: 'networking.k8s.io', version: 'v1', kind: 'NetworkPolicy', name: 'networkpolicies', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
36
36
  { group: 'discovery.k8s.io', version: 'v1', kind: 'EndpointSlice', name: 'endpointslices', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
37
37
  { group: 'autoscaling', version: 'v2', kind: 'HorizontalPodAutoscaler', name: 'horizontalpodautoscalers', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
38
+ { group: 'policy', version: 'v1', kind: 'PodDisruptionBudget', name: 'poddisruptionbudgets', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
39
+ { group: '', version: 'v1', kind: 'LimitRange', name: 'limitranges', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
40
+ { group: '', version: 'v1', kind: 'ResourceQuota', name: 'resourcequotas', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
38
41
  { group: '', version: 'v1', kind: 'Event', name: 'events', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
39
42
  { group: 'rbac.authorization.k8s.io', version: 'v1', kind: 'Role', name: 'roles', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
40
43
  { group: 'rbac.authorization.k8s.io', version: 'v1', kind: 'ClusterRole', name: 'clusterroles', namespaced: false, isCrd: false, verbs: ['list', 'get', 'watch'] },
@@ -50,6 +53,29 @@ export const CORE_RESOURCES: APIResource[] = [
50
53
  { group: 'storage.k8s.io', version: 'v1', kind: 'VolumeAttachment', name: 'volumeattachments', namespaced: false, isCrd: false, verbs: ['list', 'get', 'watch'] },
51
54
  ]
52
55
 
56
+ // These identities are built-in but must not become always-present sidebar rows.
57
+ const ADDITIONAL_BUILTIN_IDENTITIES: Record<string, string> = {
58
+ endpoints: '',
59
+ controllerrevision: 'apps', controllerrevisions: 'apps',
60
+ resourceclaim: 'resource.k8s.io', resourceclaims: 'resource.k8s.io',
61
+ resourceclaimtemplate: 'resource.k8s.io', resourceclaimtemplates: 'resource.k8s.io',
62
+ resourceslice: 'resource.k8s.io', resourceslices: 'resource.k8s.io',
63
+ deviceclass: 'resource.k8s.io', deviceclasses: 'resource.k8s.io',
64
+ };
65
+
66
+ export function builtinGroupForKind(kindOrResource: string): string | undefined {
67
+ const value = kindOrResource.toLowerCase()
68
+ return CORE_RESOURCES.find(resource =>
69
+ resource.kind.toLowerCase() === value || resource.name.toLowerCase() === value,
70
+ )?.group ?? ADDITIONAL_BUILTIN_IDENTITIES[value]
71
+ }
72
+
73
+ export function canonicalResourceGroup(kindOrResource: string, group: string | undefined): string | undefined {
74
+ const builtin = builtinGroupForKind(kindOrResource)
75
+ if (!group) return builtin
76
+ return group
77
+ }
78
+
53
79
  export function findAPIResourceForRoute(
54
80
  resources: APIResource[] | undefined,
55
81
  routeSlug: string,
@@ -103,6 +103,31 @@ describe('layerDeploymentInventory', () => {
103
103
  expect(result.managedOnly.map((node) => node.id)).toEqual(['core-service'])
104
104
  })
105
105
 
106
+ it('joins a pseudo topology node to inventory by its real Kubernetes kind', () => {
107
+ const runtime: Topology = {
108
+ nodes: [{
109
+ id: 'knativeservice/default/api',
110
+ kind: 'KnativeService',
111
+ name: 'api',
112
+ status: 'healthy',
113
+ data: { namespace: 'default', apiVersion: 'serving.knative.dev/v1', resourceKind: 'Service' },
114
+ }],
115
+ edges: [],
116
+ }
117
+ const result = layerDeploymentInventory(runtime, deploymentInventoryFromGitOps({
118
+ root: inventory().root,
119
+ edges: [],
120
+ nodes: [
121
+ inventory().root,
122
+ { id: 'knative-service', ref: { group: 'serving.knative.dev', kind: 'Service', namespace: 'default', name: 'api' }, role: 'declared', tool: 'argocd' },
123
+ ],
124
+ })!, 'Argo CD')
125
+
126
+ expect(result.managedRuntimeCount).toBe(1)
127
+ expect(result.runtimeOnlyCount).toBe(0)
128
+ expect(result.managedOnly).toEqual([])
129
+ })
130
+
106
131
  it('does not classify synthetic graph nodes as runtime-only resources', () => {
107
132
  const syntheticTopology: Topology = {
108
133
  nodes: [
@@ -1,6 +1,7 @@
1
1
  import type { GitOpsResourceTree, HealthStatus, HelmOwnedResource, Topology, TopologyNode } from '../types'
2
2
  import { apiVersionToGroup, groupQualifiesLaneId } from './navigation'
3
3
  import { pluralize } from './pluralize'
4
+ import { topologyNodeResourceKind } from './topology-neighborhood'
4
5
 
5
6
  export type DeploymentMembership = 'runtime-only' | 'source-only'
6
7
 
@@ -157,7 +158,7 @@ export function layerDeploymentInventory(
157
158
  const runtimeNodes = topology.nodes.map((node) => {
158
159
  if (node.kind === 'PodGroup' || node.kind === 'Internet') return node
159
160
  const namespace = (node.data?.namespace as string) || ''
160
- const match = inventoryByExact.get(identityKey(node.kind, topologyGroup(node), namespace, node.name))
161
+ const match = inventoryByExact.get(identityKey(topologyNodeResourceKind(node), topologyGroup(node), namespace, node.name))
161
162
  if (match) {
162
163
  matchedInventoryIds.add(match.id)
163
164
  managedRuntimeCount += 1