@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.
- package/package.json +3 -2
- package/src/components/applications/ApplicationDetail.tsx +24 -11
- package/src/components/audit/AuditAlerts.tsx +1 -3
- package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
- package/src/components/checks/ChecksView.test.tsx +55 -0
- package/src/components/checks/ChecksView.tsx +38 -27
- package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
- package/src/components/dock/NodeTerminalTab.tsx +51 -47
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
- package/src/components/gitops/detail-helpers.test.ts +32 -0
- package/src/components/gitops/detail-helpers.ts +21 -0
- package/src/components/gitops/health-provenance.test.ts +4 -3
- package/src/components/gitops/index.ts +2 -1
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
- package/src/components/issues/IssuesView.tsx +6 -0
- package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
- package/src/components/issues/diagnostic.ts +4 -0
- package/src/components/logs/LogCore.tsx +3 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
- package/src/components/logs/useLogBuffer.ts +1 -0
- package/src/components/logs/useLogStream.ts +1 -2
- package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
- package/src/components/resources/KueueAdmissionSection.tsx +87 -0
- package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +91 -16
- package/src/components/resources/curated-column-ownership.test.ts +45 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
- package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
- package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
- package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
- package/src/components/resources/renderers/index.ts +11 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
- package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
- package/src/components/resources/resource-utils-kueue.ts +145 -50
- package/src/components/resources/resource-utils-ray.test.ts +58 -1
- package/src/components/resources/resource-utils-ray.ts +56 -90
- package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
- package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
- package/src/components/timeline/TimelineList.tsx +5 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
- package/src/components/topology/K8sResourceNode.tsx +3 -3
- package/src/components/ui/CodeViewer.tsx +4 -0
- package/src/components/ui/Toast.tsx +9 -3
- package/src/components/ui/drawer-components.tsx +3 -1
- package/src/components/workload/PodList.tsx +154 -0
- package/src/components/workload/WorkloadView.tsx +29 -159
- package/src/components/workload/index.ts +11 -1
- package/src/components/workload/workload-logs-availability.test.ts +20 -0
- package/src/components/workload/workload-pod-presentation.test.ts +27 -0
- package/src/types/core.ts +24 -0
- package/src/types/gitops-tree.ts +5 -2
- package/src/types/scheduling.ts +71 -0
- package/src/utils/api-resources.test.ts +20 -1
- package/src/utils/api-resources.ts +26 -0
- package/src/utils/application-topology.test.ts +25 -0
- package/src/utils/application-topology.ts +2 -1
- package/src/utils/applications.test.ts +82 -0
- package/src/utils/applications.ts +62 -24
- package/src/utils/gitops-route.test.ts +20 -1
- package/src/utils/gitops-route.ts +15 -7
- package/src/utils/log-format.ts +5 -2
- package/src/utils/log-stream-error.test.ts +13 -0
- package/src/utils/navigation.test.ts +2 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/resource-hierarchy.test.ts +402 -15
- package/src/utils/resource-hierarchy.ts +263 -178
- package/src/utils/topology-neighborhood.test.ts +72 -1
- 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
|
-
//
|
|
445
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 {
|
|
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
|
package/src/types/gitops-tree.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|