@skyhook-io/k8s-ui 1.18.0 → 1.18.1

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 (63) hide show
  1. package/README.md +13 -0
  2. package/package.json +1 -1
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  8. package/src/components/gitops/detail-helpers.test.ts +58 -13
  9. package/src/components/gitops/detail-helpers.ts +37 -6
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/issues/IssuesView.tsx +14 -9
  12. package/src/components/logs/LogsViewer.test.tsx +104 -59
  13. package/src/components/logs/LogsViewer.tsx +28 -8
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  16. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  17. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  18. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  19. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  20. package/src/components/resources/ResourcesView.tsx +106 -19
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  23. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  24. package/src/components/resources/resource-utils-trivy.ts +122 -0
  25. package/src/components/shared/DetailShell.tsx +9 -7
  26. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  27. package/src/components/shared/ManagedByChip.tsx +24 -4
  28. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  29. package/src/components/shared/ResourceActionsBar.tsx +9 -3
  30. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  31. package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
  32. package/src/components/timeline/TimelineList.tsx +5 -1
  33. package/src/components/ui/FetchResult.test.tsx +19 -2
  34. package/src/components/ui/FetchResult.tsx +13 -1
  35. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  36. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  37. package/src/components/ui/PageHeader.tsx +6 -4
  38. package/src/components/ui/Toast.tsx +33 -26
  39. package/src/components/ui/drawer-components.test.tsx +253 -0
  40. package/src/components/ui/drawer-components.tsx +315 -11
  41. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  42. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  43. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  44. package/src/components/workload/WorkloadView.tsx +54 -6
  45. package/src/hooks/index.ts +2 -0
  46. package/src/hooks/useMediaQuery.ts +21 -0
  47. package/src/hooks/usePhoneLayout.ts +12 -0
  48. package/src/theme/tailwind-theme.css +5 -0
  49. package/src/types/core.ts +66 -2
  50. package/src/types/gitops-flux-inventory.test.ts +24 -0
  51. package/src/types/gitops.ts +5 -5
  52. package/src/types/index.ts +3 -0
  53. package/src/types/read-coverage.ts +65 -0
  54. package/src/types/resource-coverage.ts +40 -0
  55. package/src/utils/format.test.ts +7 -0
  56. package/src/utils/format.ts +6 -1
  57. package/src/utils/gitops-owner.test.ts +42 -1
  58. package/src/utils/gitops-owner.ts +22 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/read-actions.test.ts +22 -0
  61. package/src/utils/read-actions.ts +56 -0
  62. package/src/utils/resource-coverage.test.ts +121 -0
  63. package/src/utils/resource-coverage.ts +117 -0
@@ -32,6 +32,9 @@ import { getDefaultContainerName, getExecContainerNames } from '../resources/res
32
32
  import { SetImageDialog, type SetImageOwnership } from './SetImageDialog'
33
33
  import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
34
34
  import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
35
+ import { getGitOpsTool } from '../gitops/detail-helpers'
36
+ import { apiVersionToGroup } from '../../utils/navigation'
37
+ import { isApiGroup } from '../resources/resource-utils-cnpg'
35
38
  import { isArgoRolloutResource } from '../../utils/workload-rollout'
36
39
  import { isCoreBatchJob } from '../../utils/api-resources'
37
40
  import { formatRelativeAgeTime } from '../../utils/format'
@@ -40,6 +43,8 @@ import { formatRelativeAgeTime } from '../../utils/format'
40
43
  // ACTIONS BAR - Interactive buttons that change based on resource kind
41
44
  // ============================================================================
42
45
 
46
+ const FLUX_ACTION_KINDS = new Set(['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'])
47
+
43
48
  interface ResourceActionsBarProps {
44
49
  resource: SelectedResource
45
50
  leadingActions?: React.ReactNode
@@ -187,6 +192,7 @@ export function ResourceActionsBar({
187
192
  onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUpgrade,
188
193
  }: ResourceActionsBarProps) {
189
194
  const kind = resource.kind.toLowerCase()
195
+ const isFluxAction = FLUX_ACTION_KINDS.has(kind) && getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
190
196
  const coreBatchJob = isCoreBatchJob(kind, resource.group)
191
197
  const supportsWorkloadActions = ['deployments', 'statefulsets', 'daemonsets'].includes(kind) ||
192
198
  (kind === 'rollouts' && isArgoRolloutResource(data))
@@ -406,7 +412,7 @@ export function ResourceActionsBar({
406
412
  )}
407
413
 
408
414
  {/* Service actions */}
409
- {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && canPortForward && resource.namespace && resource.name && renderPortForward && (
415
+ {kind === 'services' && data?.apiVersion === 'v1' && canPortForward && resource.namespace && resource.name && renderPortForward && (
410
416
  renderPortForward({
411
417
  type: 'service',
412
418
  namespace: resource.namespace,
@@ -516,7 +522,7 @@ export function ResourceActionsBar({
516
522
  )}
517
523
 
518
524
  {/* FluxCD actions */}
519
- {['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind) && (
525
+ {isFluxAction && (
520
526
  <FluxActions
521
527
  resource={resource}
522
528
  data={data}
@@ -532,7 +538,7 @@ export function ResourceActionsBar({
532
538
  )}
533
539
 
534
540
  {/* ArgoCD actions */}
535
- {kind === 'applications' && (
541
+ {kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io') && (
536
542
  <ArgoActions
537
543
  resource={resource}
538
544
  data={data}
@@ -1087,3 +1087,52 @@ it('uses RayJob native detail only for the supported exact API', () => {
1087
1087
  if (apiVersion !== 'ray.io/v1') { expect(html).toContain('native-fallback'); expect(getResourceStatus('rayjobs', data)).toBeNull() }
1088
1088
  }
1089
1089
  })
1090
+
1091
+ describe('API group identity', () => {
1092
+ it.each([
1093
+ ['applications', 'app.k8s.io/v1beta1', 'Application'],
1094
+ ['applications', 'notargoproj.io/v1alpha1', 'Application'],
1095
+ ['services', 'vendor.serving.knative.dev/v1', 'Service'],
1096
+ ['ingresses', 'vendor.networking.internal.knative.dev/v1alpha1', 'Ingress'],
1097
+ ['certificates', 'vendor.networking.internal.knative.dev/v1alpha1', 'Certificate'],
1098
+ ['gateways', 'vendor.networking.istio.io/v1', 'Gateway'],
1099
+ ])('keeps %s in %s generic', (plural, apiVersion, kind) => {
1100
+ const data = { apiVersion, kind, metadata: { name: 'foreign', namespace: 'test' }, spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' } }
1101
+ expect(renderKind(plural, data, 'test')).toContain('custom-value')
1102
+ expect(getResourceStatus(plural, data)?.text).toBe('VendorReady')
1103
+ })
1104
+
1105
+ it('retains Argo CD status for its exact Application group', () => {
1106
+ const data = { apiVersion: 'argoproj.io/v1alpha1', kind: 'Application', metadata: { name: 'real' }, status: { sync: { status: 'Synced' }, health: { status: 'Healthy' } } }
1107
+ expect(renderKind('applications', data)).toContain('Synced')
1108
+ expect(getResourceStatus('applications', data)?.text).toBe('Healthy')
1109
+ })
1110
+
1111
+ it('retains Knative Service status and core Service presentation', () => {
1112
+ expect(getResourceStatus('services', { apiVersion: 'serving.knative.dev/v1', status: { conditions: [{ type: 'Ready', status: 'True' }] } })?.text).toBe('Ready')
1113
+ expect(renderKind('services', { apiVersion: 'v1', metadata: { name: 'core' }, spec: { type: 'ClusterIP', clusterIP: '10.0.0.1' } })).toContain('10.0.0.1')
1114
+ })
1115
+ })
1116
+
1117
+ describe('Flux kind collisions', () => {
1118
+ it.each([
1119
+ ['gitrepositories', 'source.toolkit.fluxcd.io'],
1120
+ ['ocirepositories', 'source.toolkit.fluxcd.io'],
1121
+ ['helmrepositories', 'source.toolkit.fluxcd.io'],
1122
+ ['kustomizations', 'kustomize.toolkit.fluxcd.io'],
1123
+ ['helmreleases', 'helm.toolkit.fluxcd.io'],
1124
+ ['alerts', 'notification.toolkit.fluxcd.io'],
1125
+ ])('keeps foreign %s generic and preserves its real Flux renderer', (kind, group) => {
1126
+ const resource = {
1127
+ apiVersion: `${group}/v1`, metadata: { name: 'example', namespace: 'test' },
1128
+ spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' },
1129
+ }
1130
+ expect(renderKind(kind, resource, 'test')).not.toContain('custom-value')
1131
+ expect(getResourceStatus(kind, resource)?.text).not.toBe('VendorReady')
1132
+ for (const apiVersion of ['vendor.io/v1', `foreign.${group}/v1`, 'argoproj.io/v1alpha1']) {
1133
+ const foreign = { ...resource, apiVersion }
1134
+ expect(renderKind(kind, foreign, 'test')).toContain('custom-value')
1135
+ expect(getResourceStatus(kind, foreign)?.text).toBe('VendorReady')
1136
+ }
1137
+ })
1138
+ })
@@ -1,3 +1,5 @@
1
+ import { getGitOpsTool } from '../gitops/detail-helpers'
2
+ import { apiVersionToGroup } from '../../utils/navigation'
1
3
  import { hasReflectorDetails } from '../resources/renderers/ReflectorSection'
2
4
  import { clsx } from 'clsx'
3
5
  import { SEVERITY_BADGE, HEALTH_BADGE_COLORS } from '../../utils/badge-colors'
@@ -552,6 +554,8 @@ interface ResourceRendererDispatchProps {
552
554
  resource: SelectedResource
553
555
  data: any
554
556
  relationships?: Relationships
557
+ /** See RelatedResourcesSection. */
558
+ trackedBy?: string
555
559
  certificateInfo?: SecretCertificateInfo
556
560
  hpaDiagnosis?: HPADiagnosis
557
561
  scalerDiagnostics?: ScalerDiagnosis[]
@@ -598,6 +602,7 @@ export function ResourceRendererDispatch({
598
602
  resource,
599
603
  data,
600
604
  relationships,
605
+ trackedBy,
601
606
  certificateInfo,
602
607
  hpaDiagnosis,
603
608
  scalerDiagnostics,
@@ -701,8 +706,11 @@ export function ResourceRendererDispatch({
701
706
  // foreign CRD render a BLANK drawer rather than the generic one.
702
707
  // CAPI's `machines` and `machinesets` need the same protection because another
703
708
  // API group can publish either plural without inheriting CAPI presentation.
709
+ const isFluxKind = ['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind)
710
+ const isFluxResource = getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
704
711
  const isGroupGatedKind =
705
- kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
712
+ isFluxKind || kind === 'applications' || kind === 'services' || kind === 'ingresses' || kind === 'certificates' || kind === 'gateways'
713
+ || kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
706
714
  || kind === 'objectstores' || kind === 'databases' || kind === 'publications'
707
715
  || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs' || kind === 'databaseroles' || kind === 'jobsets'
708
716
  || kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
@@ -710,7 +718,13 @@ export function ResourceRendererDispatch({
710
718
  || kind === 'machines' || kind === 'machinesets' || kind === 'workloads' || kind === 'localqueues' || kind === 'clusterqueues'
711
719
  const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
712
720
  const groupGatedMatched =
713
- (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
721
+ (isFluxKind && isFluxResource)
722
+ || (kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io'))
723
+ || (kind === 'services' && (data?.apiVersion === 'v1' || isApiGroup(data?.apiVersion, 'serving.knative.dev')))
724
+ || (kind === 'ingresses' && ['networking.k8s.io', 'extensions', 'networking.internal.knative.dev'].some(group => isApiGroup(data?.apiVersion, group)))
725
+ || (kind === 'certificates' && ['cert-manager.io', 'networking.internal.knative.dev'].some(group => isApiGroup(data?.apiVersion, group)))
726
+ || (kind === 'gateways' && ['gateway.networking.k8s.io', 'networking.istio.io'].some(group => isApiGroup(data?.apiVersion, group)))
727
+ || (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
714
728
  || (kind === 'backups' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'velero.io')))
715
729
  || ((kind === 'scheduledbackups' || kind === 'poolers') && isCNPGApiVersion)
716
730
  || (kind === 'objectstores' && isApiGroup(data?.apiVersion, 'barmancloud.cnpg.io'))
@@ -799,7 +813,7 @@ export function ResourceRendererDispatch({
799
813
 
800
814
  const sidebarContent = showCommonSections && (
801
815
  <>
802
- <RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} />
816
+ <RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} kind={kind} trackedBy={trackedBy} />
803
817
  {kind !== 'events' && <EventsSection fullscreen={!!renderSidebar} events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
804
818
  <LabelsSection data={data} />
805
819
  <AnnotationsSection data={data} />
@@ -824,9 +838,9 @@ export function ResourceRendererDispatch({
824
838
  />
825
839
  )}
826
840
  {kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
827
- {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
841
+ {kind === 'services' && data?.apiVersion === 'v1' && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
828
842
  {kind === 'endpointslices' && <EndpointSliceRenderer data={data} onNavigate={onNavigate} />}
829
- {kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
843
+ {kind === 'ingresses' && (isApiGroup(data?.apiVersion, 'networking.k8s.io') || isApiGroup(data?.apiVersion, 'extensions')) && <IngressRenderer data={data} onNavigate={onNavigate} />}
830
844
  {kind === 'configmaps' && <ConfigMapRenderer data={data} relationships={relationships} onNavigate={onNavigate} />}
831
845
  {kind === 'secrets' && <SecretRenderer data={data} relationships={relationships} onNavigate={onNavigate} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
832
846
  {kind === 'jobs' && !nonCoreJobFallthrough && <JobComp data={data} onNavigate={onNavigate} />}
@@ -848,7 +862,7 @@ export function ResourceRendererDispatch({
848
862
  {kind === 'analysisruns' && <AnalysisRunRenderer data={data} onNavigate={onNavigate} />}
849
863
  {(kind === 'analysistemplates' || kind === 'clusteranalysistemplates') && <AnalysisTemplateRenderer data={data} />}
850
864
  {kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ExperimentRenderer data={data} onNavigate={onNavigate} />}
851
- {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <CertificateRenderer data={data} />}
865
+ {kind === 'certificates' && isApiGroup(data?.apiVersion, 'cert-manager.io') && <CertificateRenderer data={data} />}
852
866
  {kind === 'workflows' && <WorkflowRenderer data={data} onNavigate={onNavigate} />}
853
867
  {kind === 'persistentvolumes' && <PersistentVolumeRenderer data={data} onNavigate={onNavigate} />}
854
868
  {kind === 'storageclasses' && <StorageClassRenderer data={data} />}
@@ -857,7 +871,7 @@ export function ResourceRendererDispatch({
857
871
  {kind === 'issuers' && <IssuerRenderer data={data} />}
858
872
  {kind === 'orders' && <OrderRenderer data={data} />}
859
873
  {kind === 'challenges' && <ChallengeRenderer data={data} />}
860
- {kind === 'gateways' && (data.apiVersion?.includes('networking.istio.io') ? <IstioGatewayRenderer data={data} /> : <GatewayRenderer data={data} onNavigate={onNavigate} />)}
874
+ {kind === 'gateways' && groupGatedMatched && (isApiGroup(data.apiVersion, 'networking.istio.io') ? <IstioGatewayRenderer data={data} /> : <GatewayRenderer data={data} onNavigate={onNavigate} />)}
861
875
  {kind === 'gatewayclasses' && <GatewayClassRenderer data={data} />}
862
876
  {kind === 'httproutes' && <HTTPRouteRenderer data={data} onNavigate={onNavigate} />}
863
877
  {kind === 'grpcroutes' && <GRPCRouteRenderer data={data} onNavigate={onNavigate} />}
@@ -877,13 +891,13 @@ export function ResourceRendererDispatch({
877
891
  {(kind === 'roles' || kind === 'clusterroles') && <RoleComp data={data} onNavigate={onNavigate} />}
878
892
  {(kind === 'rolebindings' || kind === 'clusterrolebindings') && <RoleBindingComp data={data} onNavigate={onNavigate} />}
879
893
  {kind === 'events' && <EventRenderer data={data} onNavigate={onNavigate} />}
880
- {kind === 'gitrepositories' && <GitRepositoryRenderer data={data} />}
881
- {kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
882
- {kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
883
- {kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
884
- {kind === 'helmreleases' && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
885
- {kind === 'alerts' && <AlertRenderer data={data} />}
886
- {kind === 'applications' && <ArgoApplicationRenderer data={data} />}
894
+ {kind === 'gitrepositories' && isFluxResource && <GitRepositoryRenderer data={data} />}
895
+ {kind === 'ocirepositories' && isFluxResource && <OCIRepositoryRenderer data={data} />}
896
+ {kind === 'helmrepositories' && isFluxResource && <HelmRepositoryRenderer data={data} />}
897
+ {kind === 'kustomizations' && isFluxResource && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
898
+ {kind === 'helmreleases' && isFluxResource && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
899
+ {kind === 'alerts' && isFluxResource && <AlertRenderer data={data} />}
900
+ {kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ArgoApplicationRenderer data={data} />}
887
901
  {kind === 'nodepools' && <KarpenterNodePoolComp data={data} onNavigate={onNavigate} />}
888
902
  {kind === 'nodeclaims' && <KarpenterNodeClaimRenderer data={data} onNavigate={onNavigate} />}
889
903
  {kind === 'ec2nodeclasses' && <KarpenterEC2NodeClassRenderer data={data} />}
@@ -989,14 +1003,14 @@ export function ResourceRendererDispatch({
989
1003
  {kind === 'leases' && <LeaseRenderer data={data} />}
990
1004
  {kind === 'limitranges' && <LimitRangeRenderer data={data} />}
991
1005
  {/* Knative Serving */}
992
- {(kind === 'services' && data?.apiVersion?.includes('serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
1006
+ {(kind === 'services' && isApiGroup(data?.apiVersion, 'serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
993
1007
  {kind === 'knativeservices' && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
994
1008
  {kind === 'knativeconfigurations' && <KnativeConfigurationRenderer data={data} onNavigate={onNavigate} />}
995
1009
  {kind === 'knativerevisions' && <KnativeRevisionRenderer data={data} onNavigate={onNavigate} />}
996
1010
  {kind === 'knativeroutes' && <KnativeRouteRenderer data={data} onNavigate={onNavigate} />}
997
- {(kind === 'ingresses' && data?.apiVersion?.includes('networking.internal.knative.dev')) && <KnativeIngressRenderer data={data} />}
1011
+ {(kind === 'ingresses' && isApiGroup(data?.apiVersion, 'networking.internal.knative.dev')) && <KnativeIngressRenderer data={data} />}
998
1012
  {kind === 'knativeingresses' && <KnativeIngressRenderer data={data} />}
999
- {(kind === 'certificates' && data?.apiVersion?.includes('networking.internal.knative.dev')) && <KnativeCertificateRenderer data={data} />}
1013
+ {(kind === 'certificates' && isApiGroup(data?.apiVersion, 'networking.internal.knative.dev')) && <KnativeCertificateRenderer data={data} />}
1000
1014
  {kind === 'knativecertificates' && <KnativeCertificateRenderer data={data} />}
1001
1015
  {kind === 'serverlessservices' && <ServerlessServiceRenderer data={data} onNavigate={onNavigate} />}
1002
1016
  {kind === 'domainmappings' && <DomainMappingRenderer data={data} onNavigate={onNavigate} />}
@@ -1101,11 +1115,11 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1101
1115
  }
1102
1116
  if (k === 'replicasets') return getWorkloadStatus(data, k)
1103
1117
  if (k === 'services') {
1104
- if (data.apiVersion?.includes('serving.knative.dev')) {
1118
+ if (isApiGroup(data.apiVersion, 'serving.knative.dev')) {
1105
1119
  const status = getKnativeConditionStatus(data)
1106
1120
  return { text: status.text, color: status.color }
1107
1121
  }
1108
- return getServiceStatus(data)
1122
+ if (data.apiVersion === 'v1') return getServiceStatus(data)
1109
1123
  }
1110
1124
  if (k === 'endpointslices') {
1111
1125
  const endpoints = data.endpoints || []
@@ -1178,11 +1192,11 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1178
1192
  if (k === 'workflows') return getWorkflowStatus(data)
1179
1193
  if (k === 'cronworkflows') return getCronWorkflowStatus(data)
1180
1194
  if (k === 'certificates') {
1181
- if (data.apiVersion?.includes('networking.internal.knative.dev')) {
1195
+ if (isApiGroup(data.apiVersion, 'networking.internal.knative.dev')) {
1182
1196
  const status = getKnativeConditionStatus(data)
1183
1197
  return { text: status.text, color: status.color }
1184
1198
  }
1185
- return getCertificateStatus(data)
1199
+ if (isApiGroup(data.apiVersion, 'cert-manager.io')) return getCertificateStatus(data)
1186
1200
  }
1187
1201
  if (k === 'persistentvolumes') return getPVStatus(data)
1188
1202
  if (k === 'certificaterequests') return getCertificateRequestStatus(data)
@@ -1191,20 +1205,21 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1191
1205
  if (k === 'orders') return getOrderState(data)
1192
1206
  if (k === 'challenges') return getChallengeState(data)
1193
1207
  if (k === 'gateways') {
1194
- if (data.apiVersion?.includes('networking.istio.io')) return getIstioGatewayStatus(data)
1195
- return getGatewayStatus(data)
1208
+ if (isApiGroup(data.apiVersion, 'networking.istio.io')) return getIstioGatewayStatus(data)
1209
+ if (isApiGroup(data.apiVersion, 'gateway.networking.k8s.io')) return getGatewayStatus(data)
1196
1210
  }
1197
1211
  if (k === 'gatewayclasses') return getGatewayClassStatus(data)
1198
1212
  if (k === 'httproutes' || k === 'grpcroutes' || k === 'tcproutes' || k === 'tlsroutes' || k === 'udproutes') return getRouteStatus(data)
1199
1213
  if (k === 'sealedsecrets') return getSealedSecretStatus(data)
1200
1214
  if (k === 'poddisruptionbudgets') return getPDBStatus(data)
1201
- if (k === 'gitrepositories') return getGitRepositoryStatus(data)
1202
- if (k === 'ocirepositories') return getOCIRepositoryStatus(data)
1203
- if (k === 'helmrepositories') return getHelmRepositoryStatus(data)
1204
- if (k === 'kustomizations') return getKustomizationStatus(data)
1205
- if (k === 'helmreleases') return getFluxHelmReleaseStatus(data)
1206
- if (k === 'alerts') return getFluxAlertStatus(data)
1207
- if (k === 'applications') return getArgoApplicationStatus(data)
1215
+ const isFluxResource = getGitOpsTool(k, apiVersionToGroup(data.apiVersion)) === 'flux'
1216
+ if (k === 'gitrepositories' && isFluxResource) return getGitRepositoryStatus(data)
1217
+ if (k === 'ocirepositories' && isFluxResource) return getOCIRepositoryStatus(data)
1218
+ if (k === 'helmrepositories' && isFluxResource) return getHelmRepositoryStatus(data)
1219
+ if (k === 'kustomizations' && isFluxResource) return getKustomizationStatus(data)
1220
+ if (k === 'helmreleases' && isFluxResource) return getFluxHelmReleaseStatus(data)
1221
+ if (k === 'alerts' && isFluxResource) return getFluxAlertStatus(data)
1222
+ if (k === 'applications' && isApiGroup(data.apiVersion, 'argoproj.io')) return getArgoApplicationStatus(data)
1208
1223
  if (k === 'nodepools') return getNodePoolStatus(data)
1209
1224
  if (k === 'nodeclaims') return getNodeClaimStatus(data)
1210
1225
  if (k === 'ec2nodeclasses') return getEC2NodeClassStatus(data)
@@ -1349,7 +1364,7 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1349
1364
  if (
1350
1365
  knativeConditionKinds.includes(k)
1351
1366
  || isKnativeSubscription
1352
- || (k === 'ingresses' && data.apiVersion?.includes('networking.internal.knative.dev'))
1367
+ || (k === 'ingresses' && isApiGroup(data.apiVersion, 'networking.internal.knative.dev'))
1353
1368
  ) {
1354
1369
  const status = getKnativeConditionStatus(data)
1355
1370
  return { text: status.text, color: status.color }
@@ -216,6 +216,10 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
216
216
  const singleEvents: TimelineEvent[] = []
217
217
 
218
218
  for (const item of items) {
219
+ if (item.id === selectedEventId) {
220
+ singleEvents.push(item)
221
+ continue
222
+ }
219
223
  if (foldPerResource?.(item)) {
220
224
  const key = `fold:${item.kind}:${item.namespace}:${item.name}`
221
225
  const existing = groups.get(key) || []
@@ -344,7 +348,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
344
348
  if (older.length > 0) groups.push({ label: 'Older', items: aggregateEvents(older) })
345
349
 
346
350
  return groups
347
- }, [filteredActivity, compact, foldPerResource])
351
+ }, [filteredActivity, compact, foldPerResource, selectedEventId])
348
352
 
349
353
  // Visible-window reporting: rows carry their time span in data attributes;
350
354
  // on scroll (rAF-throttled) and whenever the row set changes, the span of
@@ -54,11 +54,12 @@ describe('FetchResult', () => {
54
54
  expect(html).toContain('Resource cache not available')
55
55
  })
56
56
 
57
- it('does not blame the cluster for a kind_sync_failed 503', () => {
57
+ // kind_sync_failed is how Radar releases before kind_sync_timed_out spelled it.
58
+ it.each(['kind_sync_timed_out', 'kind_sync_failed'])('does not blame the cluster for a %s 503', (code) => {
58
59
  const html = renderToString(
59
60
  <FetchResult
60
61
  loading={false}
61
- error={shaped('pods failed to load within the sync deadline', 503, { error_code: 'kind_sync_failed' })}
62
+ error={shaped('pods failed to load within the sync deadline', 503, { error_code: code })}
62
63
  />,
63
64
  )
64
65
  expect(html).not.toContain('Cluster unavailable')
@@ -66,6 +67,22 @@ describe('FetchResult', () => {
66
67
  expect(html).toContain('pods failed to load within the sync deadline')
67
68
  })
68
69
 
70
+ it('blames Radar\'s collector, not the cluster, for a collector failure', () => {
71
+ const html = renderToString(
72
+ <FetchResult loading={false} error={shaped('Radar is not allowed to list pods', 503, { error_code: 'radar_forbidden' })} />,
73
+ )
74
+ expect(html).toContain('Radar can')
75
+ expect(html).not.toContain('Cluster unavailable')
76
+ })
77
+
78
+ it('keeps a disconnected cluster reading as unavailable', () => {
79
+ const html = renderToString(
80
+ <FetchResult loading={false} error={shaped('Not connected to cluster', 503, { error_code: 'cluster_disconnected' })} />,
81
+ )
82
+ expect(html).toContain('Cluster unavailable')
83
+ expect(html).not.toContain('Radar can')
84
+ })
85
+
69
86
  it('renders a Retry button only when onRetry is given', () => {
70
87
  const err = shaped('internal server error', 500)
71
88
  expect(renderToString(<FetchResult loading={false} error={err} onRetry={() => {}} />)).toContain('Retry')
@@ -3,6 +3,7 @@ import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, RefreshCw, t
3
3
  import { clsx } from 'clsx'
4
4
  import { PaneLoader } from './PaneLoader'
5
5
  import { isFetchError } from '../../types/fetch-error'
6
+ import { readActionFor, canonicalReadCode } from '../../utils/read-actions'
6
7
 
7
8
  // FetchResult collapses (loading, error, no-data) into one rendered outcome:
8
9
  // loader while loading, a typed error surface when the fetch threw, or the
@@ -15,6 +16,7 @@ import { isFetchError } from '../../types/fetch-error'
15
16
  // error (403) → "Access denied" + error.message
16
17
  // error (404) → notFoundMessage + error.message
17
18
  // error (503 sync failed) → "Couldn't load this view" + error.message
19
+ // error (503 collector) → "Radar can't read this" + error.message
18
20
  // error (503) → "Cluster unavailable" + error.message
19
21
  // error (401) → "Sign-in required" (apiFetch redirects; fallback)
20
22
  // error (other / no shape) → "Couldn't load this view" + error.message
@@ -118,9 +120,19 @@ function classify(error: unknown, notFoundMessage: string): Classified {
118
120
  case 503:
119
121
  // The cluster is connected; one kind's informer missed its sync
120
122
  // deadline, so "Cluster unavailable" would send the reader the wrong way.
121
- if (errorCodeOf(error) === 'kind_sync_failed') {
123
+ if (canonicalReadCode(errorCodeOf(error)) === 'kind_sync_timed_out') {
122
124
  return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
123
125
  }
126
+ // Shares the collector's action, but it is the cluster connection
127
+ // that is down rather than one read.
128
+ if (errorCodeOf(error) === 'cluster_disconnected') {
129
+ return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
130
+ }
131
+ // Radar's own identity or collection can't serve the read: neither
132
+ // the cluster being down nor the reader's access.
133
+ if (readActionFor(errorCodeOf(error)) === 'radar_config') {
134
+ return { headline: "Radar can't read this", detail: error.message, icon: ShieldOff }
135
+ }
124
136
  // The cluster is healthy and still loading — the retry policies keep
125
137
  // these surfaces polling, but a caller without one must not tell the
126
138
  // user a connecting cluster is down.
@@ -36,6 +36,26 @@ describe('MultiSelectPicker', () => {
36
36
  expect(html).toContain('>Done</button>')
37
37
  })
38
38
 
39
+ it('adds an Only button to each row only when a select-only handler is given', () => {
40
+ expect(render()).not.toContain('>Only</button>')
41
+
42
+ const html = render({ onSelectOnly: () => {} })
43
+ expect(html.match(/>Only<\/button>/g)).toHaveLength(KINDS.length)
44
+ expect(html).toContain('aria-label="Show only Pod"')
45
+ // The name stays inside the checkbox label, so clicking it still toggles.
46
+ expect(html).toMatch(/<label[^>]*><span[^>]*><input type="checkbox"[^>]*\/><span class="truncate">Pod<\/span><\/span><\/label><button/)
47
+ })
48
+
49
+ it('hides Only until hover or keyboard focus, and keeps it visible on touch', () => {
50
+ const html = render({ onSelectOnly: () => {} })
51
+ const only = html.match(/<button[^>]*aria-label="Show only Pod"[^>]*>/)?.[0] ?? ''
52
+ expect(only).toContain('opacity-0')
53
+ expect(only).toContain('group-hover/row:opacity-100')
54
+ expect(only).toContain('group-has-[:focus-visible]/row:opacity-100')
55
+ expect(only).toContain('focus-visible:opacity-100')
56
+ expect(only).toContain('any-pointer-coarse:opacity-100')
57
+ })
58
+
39
59
  it('shows the filter input above the threshold and hides it below', () => {
40
60
  const shown = render()
41
61
  expect(shown).toContain('placeholder="Filter kinds"')
@@ -16,6 +16,12 @@ export interface MultiSelectPickerProps {
16
16
  onClearAll: () => void
17
17
  /** Footer "Done" button. */
18
18
  onDone: () => void
19
+ /**
20
+ * When set, each row gets an "Only" button that selects just that item. The
21
+ * name and checkbox still toggle. The host decides whether it also commits
22
+ * and closes.
23
+ */
24
+ onSelectOnly?: (item: string) => void
19
25
 
20
26
  /** Controlled search term (kept in the host so the body stays SSR-testable). */
21
27
  search: string
@@ -50,6 +56,7 @@ export function MultiSelectPicker({
50
56
  onSelectionChange,
51
57
  onClearAll,
52
58
  onDone,
59
+ onSelectOnly,
53
60
  search,
54
61
  onSearchChange,
55
62
  searchPlaceholder,
@@ -136,8 +143,8 @@ export function MultiSelectPicker({
136
143
  )}
137
144
 
138
145
  {filteredItems.map(item => (
139
- <li key={item}>
140
- <label className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer">
146
+ <li key={item} className="group/row flex items-center hover:bg-theme-hover">
147
+ <label className="flex-1 min-w-0 flex items-center px-3 py-1.5 text-sm text-left text-theme-text-primary cursor-pointer">
141
148
  <span className="flex items-center gap-2 min-w-0">
142
149
  <input
143
150
  type="checkbox"
@@ -149,6 +156,17 @@ export function MultiSelectPicker({
149
156
  {renderItemMeta?.(item)}
150
157
  </span>
151
158
  </label>
159
+ {onSelectOnly && (
160
+ // Touchscreens can coexist with a hover-capable mouse.
161
+ <button
162
+ type="button"
163
+ onClick={() => onSelectOnly(item)}
164
+ aria-label={`Show only ${item}`}
165
+ className="shrink-0 mr-2 px-1.5 py-0.5 rounded border border-theme-border bg-theme-surface text-[11px] text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary opacity-0 transition-opacity group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 focus-visible:opacity-100 any-pointer-coarse:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
166
+ >
167
+ Only
168
+ </button>
169
+ )}
152
170
  </li>
153
171
  ))}
154
172
  </ul>
@@ -18,7 +18,9 @@ interface PageHeaderProps {
18
18
 
19
19
  export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
20
20
  return (
21
- <div className="flex items-center gap-4">
21
+ // Phones: actions (freshness, summary tiles) wrap under the title
22
+ // instead of squeezing it to nothing.
23
+ <div className="flex items-center gap-4 phone:flex-wrap phone:gap-y-3">
22
24
  {onBack && (
23
25
  <button
24
26
  onClick={onBack}
@@ -28,16 +30,16 @@ export function PageHeader({ icon: Icon, title, description, onBack, actions }:
28
30
  <ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
29
31
  </button>
30
32
  )}
31
- <div className="flex-1 min-w-0">
33
+ <div className="flex-1 min-w-0 phone:basis-full">
32
34
  <div className="flex items-center gap-2">
33
35
  <Icon className="w-5 h-5 text-theme-text-secondary shrink-0" />
34
36
  <h1 className="text-lg font-semibold text-theme-text-primary truncate">{title}</h1>
35
37
  </div>
36
38
  {description && (
37
- <p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate">{description}</p>
39
+ <p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate phone:whitespace-normal phone:line-clamp-2">{description}</p>
38
40
  )}
39
41
  </div>
40
- {actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0">{actions}</div>}
42
+ {actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0 phone:shrink phone:justify-start">{actions}</div>}
41
43
  </div>
42
44
  )
43
45
  }
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'
1
+ import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from 'react'
2
2
  import { DURATION_TOAST_EXIT } from '../../utils/animation'
3
3
  import { Check, Terminal, X, AlertTriangle, Info } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
@@ -15,15 +15,13 @@ interface Toast {
15
15
  onDetailClick?: () => void
16
16
  }
17
17
 
18
+ type ToastOptions = Omit<Toast, 'id' | 'message' | 'dismissing'> & {
19
+ id?: string
20
+ duration?: number
21
+ }
22
+
18
23
  interface ToastContextType {
19
- showToast: (message: string, options?: {
20
- detail?: string
21
- command?: string
22
- type?: Toast['type']
23
- position?: { x: number; y: number }
24
- action?: Toast['action']
25
- onDetailClick?: () => void
26
- }) => void
24
+ showToast: (message: string, options?: ToastOptions) => void
27
25
  showCopied: (command: string, label?: string, event?: React.MouseEvent) => void
28
26
  showError: (message: string, detail?: string) => void
29
27
  showSuccess: (message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void
@@ -94,34 +92,42 @@ export function useToast() {
94
92
 
95
93
  export function ToastProvider({ children }: { children: ReactNode }) {
96
94
  const [toasts, setToasts] = useState<Toast[]>([])
95
+ const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
97
96
 
98
97
  const animateDismiss = useCallback((id: string) => {
98
+ clearTimeout(timers.current.get(id))
99
99
  setToasts(prev => prev.map(t => t.id === id ? { ...t, dismissing: true } : t))
100
- setTimeout(() => {
100
+ timers.current.set(id, setTimeout(() => {
101
101
  setToasts(prev => prev.filter(t => t.id !== id))
102
- }, DURATION_TOAST_EXIT)
102
+ timers.current.delete(id)
103
+ }, DURATION_TOAST_EXIT))
103
104
  }, [])
104
105
 
105
- const showToast = useCallback((message: string, options?: {
106
- detail?: string
107
- command?: string
108
- type?: Toast['type']
109
- position?: { x: number; y: number }
110
- action?: Toast['action']
111
- onDetailClick?: () => void
112
- }) => {
113
- const id = Math.random().toString(36).slice(2)
114
- const toast: Toast = { id, message, ...options }
106
+ const showToast = useCallback((message: string, options?: ToastOptions) => {
107
+ const { id = Math.random().toString(36).slice(2), duration, ...toastOptions } = options ?? {}
108
+ const toast: Toast = { id, message, ...toastOptions }
115
109
 
116
- setToasts(prev => [...prev, toast])
110
+ clearTimeout(timers.current.get(id))
111
+ timers.current.delete(id)
112
+ setToasts(prev => prev.some(t => t.id === id)
113
+ ? prev.map(t => t.id === id ? toast : t)
114
+ : [...prev, toast])
117
115
 
118
116
  // Auto-dismiss: errors stay longer (10s), others 7s
119
- const dismissTime = options?.type === 'error' ? 10000 : 7000
120
- setTimeout(() => {
121
- animateDismiss(id)
122
- }, dismissTime)
117
+ const dismissTime = duration ?? (toast.type === 'error' ? 10000 : 7000)
118
+ if (dismissTime > 0) {
119
+ timers.current.set(id, setTimeout(() => animateDismiss(id), dismissTime))
120
+ }
123
121
  }, [animateDismiss])
124
122
 
123
+ useEffect(() => {
124
+ const activeTimers = timers.current
125
+ return () => {
126
+ activeTimers.forEach(clearTimeout)
127
+ activeTimers.clear()
128
+ }
129
+ }, [])
130
+
125
131
  const showCopied = useCallback((command: string, label?: string, event?: React.MouseEvent) => {
126
132
  navigator.clipboard.writeText(command)
127
133
 
@@ -187,6 +193,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
187
193
 
188
194
  return (
189
195
  <div
196
+ role="status"
190
197
  className={clsx(
191
198
  'flex items-start gap-3 p-4 rounded-lg shadow-2xl border backdrop-blur-sm',
192
199
  'w-[480px] max-w-[calc(100vw-32px)]',