@skyhook-io/k8s-ui 1.8.11 → 1.8.13

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 (87) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.test.tsx +136 -8
  3. package/src/components/applications/ApplicationDetail.tsx +1215 -487
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  5. package/src/components/gitops/GitOpsTableView.tsx +10 -3
  6. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  7. package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
  8. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  9. package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
  10. package/src/components/gitops/insights/index.ts +1 -0
  11. package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
  12. package/src/components/gitops/insights/insights-helpers.ts +5 -0
  13. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
  14. package/src/components/issues/IssuesView.tsx +55 -29
  15. package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
  16. package/src/components/issues/ResourceIssuesSection.tsx +21 -1
  17. package/src/components/issues/severity.ts +1 -0
  18. package/src/components/resources/ResourcesView.tsx +109 -19
  19. package/src/components/resources/column-filter-serialization.test.ts +79 -1
  20. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  40. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  44. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  54. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  55. package/src/components/resources/resource-utils-argo.ts +8 -0
  56. package/src/components/resources/resource-utils.ts +94 -25
  57. package/src/components/shared/ResourceActionsBar.tsx +54 -22
  58. package/src/components/shared/UnifiedDiff.tsx +51 -0
  59. package/src/components/shared/index.ts +1 -0
  60. package/src/components/timeline/TimelineList.tsx +7 -3
  61. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  62. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  63. package/src/components/topology/TopologyControls.tsx +3 -1
  64. package/src/components/topology/TopologyGraph.tsx +76 -90
  65. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  66. package/src/components/topology/TopologySearch.tsx +23 -15
  67. package/src/components/topology/index.ts +2 -0
  68. package/src/components/ui/Badge.tsx +1 -1
  69. package/src/components/ui/SelectMenu.tsx +81 -0
  70. package/src/components/ui/Tooltip.test.tsx +21 -0
  71. package/src/components/ui/Tooltip.tsx +9 -12
  72. package/src/components/ui/drawer-components.tsx +9 -6
  73. package/src/components/ui/index.ts +2 -0
  74. package/src/components/workload/WorkloadView.tsx +24 -3
  75. package/src/theme/tailwind-theme.css +1 -0
  76. package/src/theme/variables.css +1 -0
  77. package/src/types/gitops-insights.ts +69 -1
  78. package/src/types/gitops.ts +3 -3
  79. package/src/utils/api-resources.test.ts +36 -1
  80. package/src/utils/api-resources.ts +16 -0
  81. package/src/utils/application-history.test.ts +538 -0
  82. package/src/utils/application-history.ts +257 -0
  83. package/src/utils/applications.test.ts +34 -0
  84. package/src/utils/applications.ts +25 -0
  85. package/src/utils/index.ts +1 -0
  86. package/src/utils/resource-hierarchy.test.ts +35 -1
  87. package/src/utils/resource-hierarchy.ts +20 -0
@@ -1,5 +1,5 @@
1
1
  import { ShieldCheck } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, useOperationalIssuesShown } from '../../ui/drawer-components'
3
3
  import {
4
4
  getSecretStoreStatus,
5
5
  getSecretStoreProviderType,
@@ -41,11 +41,12 @@ export function SecretStoreRenderer({ data }: SecretStoreRendererProps) {
41
41
  // Problem detection
42
42
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
43
43
  const isNotReady = readyCond?.status === 'False'
44
+ const operationalIssuesShown = useOperationalIssuesShown()
44
45
 
45
46
  return (
46
47
  <>
47
48
  {/* Alert if not ready */}
48
- {isNotReady && (
49
+ {isNotReady && !operationalIssuesShown && (
49
50
  <AlertBanner
50
51
  variant="error"
51
52
  title="Store Not Ready"
@@ -1,5 +1,5 @@
1
1
  import { Globe, ArrowRight, Network } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceRefBadge } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceRefBadge, useOperationalIssuesShown } from '../../ui/drawer-components'
3
3
  import { Badge } from '../../ui/Badge'
4
4
  import type { ResourceRef } from '../../../types'
5
5
 
@@ -26,6 +26,10 @@ export function SimpleRouteRenderer({ data, kind, onNavigate }: SimpleRouteRende
26
26
  const unresolvedRefsParents = parentStatuses.filter((p: any) =>
27
27
  (p.conditions || []).some((c: any) => c.type === 'ResolvedRefs' && c.status === 'False')
28
28
  )
29
+ // The Operational Issues section (when the host shows it) already reports these
30
+ // Accepted/ResolvedRefs failures with richer cause + next-step context, so drop
31
+ // the renderer's own banners to avoid showing the same failure twice.
32
+ const operationalIssuesShown = useOperationalIssuesShown()
29
33
 
30
34
  const firstParentConditions = parentStatuses.length > 0
31
35
  ? parentStatuses[0].conditions
@@ -50,7 +54,7 @@ export function SimpleRouteRenderer({ data, kind, onNavigate }: SimpleRouteRende
50
54
 
51
55
  return (
52
56
  <>
53
- {notAcceptedParents.length > 0 && (
57
+ {notAcceptedParents.length > 0 && !operationalIssuesShown && (
54
58
  <AlertBanner
55
59
  variant="error"
56
60
  title="Route Not Accepted"
@@ -64,7 +68,7 @@ export function SimpleRouteRenderer({ data, kind, onNavigate }: SimpleRouteRende
64
68
  />
65
69
  )}
66
70
 
67
- {unresolvedRefsParents.length > 0 && (
71
+ {unresolvedRefsParents.length > 0 && !operationalIssuesShown && (
68
72
  <AlertBanner
69
73
  variant="warning"
70
74
  title="Unresolved References"
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { getArgoApplicationStatus } from './resource-utils-argo'
2
+ import { getArgoApplicationStatus, isArgoOperationInProgress } from './resource-utils-argo'
3
3
 
4
4
  describe('getArgoApplicationStatus', () => {
5
5
  // A Suspended Argo app is intentionally paused — neutral (sky), matching the
@@ -20,4 +20,21 @@ describe('getArgoApplicationStatus', () => {
20
20
  const badge = getArgoApplicationStatus({ status: { health: { status: 'Degraded' }, sync: { status: 'Synced' } } })
21
21
  expect(badge.level).toBe('unhealthy')
22
22
  })
23
+
24
+ it('surfaces an operation being terminated', () => {
25
+ const badge = getArgoApplicationStatus({ status: { operationState: { phase: 'Terminating' } } })
26
+ expect(badge.level).toBe('degraded')
27
+ expect(badge.text).toBe('Terminating')
28
+ })
29
+ })
30
+
31
+ describe('isArgoOperationInProgress', () => {
32
+ it.each([
33
+ [{ status: { operationState: { phase: 'Running' } } }, true],
34
+ [{ status: { operationState: { phase: 'Terminating' } } }, true],
35
+ [{ operation: { sync: {} }, status: { operationState: { phase: 'Succeeded' } } }, true],
36
+ [{ status: { operationState: { phase: 'Succeeded' } } }, false],
37
+ ])('maps %o to %s', (app, expected) => {
38
+ expect(isArgoOperationInProgress(app)).toBe(expected)
39
+ })
23
40
  })
@@ -33,6 +33,9 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
33
33
  if (opPhase === 'Running') {
34
34
  return { text: 'Syncing', color: healthColors.degraded, level: 'degraded' }
35
35
  }
36
+ if (opPhase === 'Terminating') {
37
+ return { text: 'Terminating', color: healthColors.degraded, level: 'degraded' }
38
+ }
36
39
 
37
40
  // Failed operation
38
41
  if (opPhase === 'Failed' || opPhase === 'Error') {
@@ -61,6 +64,11 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
61
64
  return { text: health || sync || 'Unknown', color: healthColors.unknown, level: 'unknown' }
62
65
  }
63
66
 
67
+ export function isArgoOperationInProgress(app: any): boolean {
68
+ const phase = app?.status?.operationState?.phase
69
+ return app?.operation != null || phase === 'Running' || phase === 'Terminating'
70
+ }
71
+
64
72
  // Radar suspends an Argo Application by clearing spec.syncPolicy.automated and
65
73
  // recording the prior prune/selfHeal flags in annotations so Resume can restore
66
74
  // them. The *presence* of any of these annotations marks the app as suspended —
@@ -1827,40 +1827,109 @@ export function formatResources(resources: any): string {
1827
1827
  * Used by the generic column filter system to match resources against filter values.
1828
1828
  * Reuses existing utility functions for kind-specific columns.
1829
1829
  */
1830
- // Parse column filters from URL `filters` param (format: "col:val1,val2|col2:val3")
1831
- // Uses `|` as pair separator between columns, `,` between values within a column.
1832
- // Multi-select: each column key maps to an array of selected values.
1830
+ // Parse column filters from URL `filters` param. Each column is either
1831
+ // "col:val1,val2" (implicit include) or "col:exclude:val1,val2" (explicit
1832
+ // operator). `|` separates columns, `,` separates values within a column.
1833
+ // Keys and values are URI-encoded so their own delimiters survive; the operator,
1834
+ // when present, is the literal token "include" or "exclude".
1833
1835
  export function parseColumnFilters(filtersParam: string | null): Record<string, string[]> {
1834
- if (!filtersParam) return {}
1835
- const filters: Record<string, string[]> = {}
1836
+ // Prototype-less accumulator: the keys come from the URL, so a plain object
1837
+ // would expose Object.prototype as a write target (js/remote-property-injection).
1838
+ const filters: Record<string, string[]> = Object.create(null)
1839
+ if (!filtersParam) return filters
1836
1840
  for (const pair of filtersParam.split('|')) {
1837
- const colonIdx = pair.indexOf(':')
1838
- if (colonIdx > 0) {
1839
- const rawKey = pair.slice(0, colonIdx).trim()
1840
- const valStr = pair.slice(colonIdx + 1).trim()
1841
- // Keys are URI-encoded so a custom-column key's own colon (e.g.
1842
- // "label:tier") doesn't collide with the key:value delimiter.
1843
- let key: string
1844
- try { key = decodeURIComponent(rawKey) } catch { key = rawKey }
1845
- if (key && valStr) {
1846
- filters[key] = valStr.split(',').map(v => {
1847
- try { return decodeURIComponent(v.trim()) } catch { return v.trim() }
1848
- }).filter(Boolean)
1849
- }
1841
+ const parsed = parseColumnFilterPair(pair)
1842
+ // Guard the dynamic write: the key comes from the URL, so reject the
1843
+ // prototype-polluting names right at the assignment (js/remote-property-injection).
1844
+ if (parsed && parsed.key !== '__proto__' && parsed.key !== 'prototype' && parsed.key !== 'constructor') {
1845
+ filters[parsed.key] = parsed.values
1850
1846
  }
1851
1847
  }
1852
1848
  return filters
1853
1849
  }
1854
1850
 
1855
- // Serialize column filters to URL param format. Keys and values are both
1856
- // URI-encoded so a colon inside a custom-column key (e.g. "label:tier") or a
1857
- // comma inside a value (e.g. "Ready,SchedulingDisabled") survives the round-trip.
1858
- export function serializeColumnFilters(filters: Record<string, string[]>): string {
1859
- const result = Object.entries(filters)
1851
+ // Serialize column filters to URL param format. Columns listed in `excludes`
1852
+ // emit the explicit "exclude" operator; all others keep the backwards-compatible
1853
+ // two-part shape. Keys and values are both URI-encoded so a colon inside a
1854
+ // custom-column key (e.g. "label:tier") or a comma inside a value (e.g.
1855
+ // "Ready,SchedulingDisabled") survives the round-trip.
1856
+ export function serializeColumnFilters(
1857
+ filters: Record<string, string[]>,
1858
+ excludes?: Record<string, boolean>,
1859
+ ): string {
1860
+ return Object.entries(filters)
1860
1861
  .filter(([, v]) => v.length > 0)
1861
- .map(([k, vals]) => `${encodeURIComponent(k)}:${vals.map(v => encodeURIComponent(v)).join(',')}`)
1862
+ .map(([k, vals]) => {
1863
+ const op = excludes?.[k] ? 'exclude:' : ''
1864
+ return `${encodeURIComponent(k)}:${op}${vals.map(v => encodeURIComponent(v)).join(',')}`
1865
+ })
1862
1866
  .join('|')
1863
- return result
1867
+ }
1868
+
1869
+ // Which columns are in exclude mode (show non-matching rows). Read from the same
1870
+ // `filters` param so the operator can't drift out of sync with the values it
1871
+ // negates — a lone exclude operator with no values is structurally impossible.
1872
+ export function parseColumnFilterExcludes(filtersParam: string | null): Record<string, boolean> {
1873
+ // Prototype-less accumulator: see parseColumnFilters (js/remote-property-injection).
1874
+ const excludes: Record<string, boolean> = Object.create(null)
1875
+ if (!filtersParam) return excludes
1876
+ for (const pair of filtersParam.split('|')) {
1877
+ const parsed = parseColumnFilterPair(pair)
1878
+ // Guard the dynamic write against prototype-polluting keys from the URL
1879
+ // right at the assignment (js/remote-property-injection).
1880
+ if (
1881
+ parsed && parsed.values.length &&
1882
+ parsed.key !== '__proto__' && parsed.key !== 'prototype' && parsed.key !== 'constructor'
1883
+ ) {
1884
+ // Values are last-write-wins per key in parseColumnFilters, so the mode
1885
+ // must track the same final segment: a later include overrides an
1886
+ // earlier exclude for the same column, otherwise the two disagree.
1887
+ if (parsed.exclude) {
1888
+ excludes[parsed.key] = true
1889
+ } else {
1890
+ delete excludes[parsed.key]
1891
+ }
1892
+ }
1893
+ }
1894
+ return excludes
1895
+ }
1896
+
1897
+ // Split a single "col[:operator]:values" pair into its decoded key, values, and
1898
+ // whether the explicit exclude operator was present. The first literal colon
1899
+ // delimits key from the rest (keys are encoded, so their own colons don't
1900
+ // count); an optional leading "include"/"exclude" token in the remainder is the
1901
+ // operator. Any other leading token is treated as a value (two-part form), so a
1902
+ // value literally named "exclude" without a following colon round-trips.
1903
+ function parseColumnFilterPair(
1904
+ pair: string,
1905
+ ): { key: string; values: string[]; exclude: boolean } | null {
1906
+ const colonIdx = pair.indexOf(':')
1907
+ if (colonIdx <= 0) return null
1908
+ const rawKey = pair.slice(0, colonIdx).trim()
1909
+ let rest = pair.slice(colonIdx + 1).trim()
1910
+ let key: string
1911
+ try { key = decodeURIComponent(rawKey) } catch { key = rawKey }
1912
+ if (!key) return null
1913
+ // Guard against prototype-pollution: the key becomes an object property name
1914
+ // downstream, and it comes straight from the URL. Reject the well-known
1915
+ // dangerous names so a crafted `filters` param can't touch the prototype.
1916
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') return null
1917
+
1918
+ let exclude = false
1919
+ const opIdx = rest.indexOf(':')
1920
+ if (opIdx >= 0) {
1921
+ const op = rest.slice(0, opIdx).trim().toLowerCase()
1922
+ if (op === 'exclude' || op === 'include') {
1923
+ exclude = op === 'exclude'
1924
+ rest = rest.slice(opIdx + 1).trim()
1925
+ }
1926
+ }
1927
+ if (!rest) return null
1928
+ const values = rest.split(',').map(v => {
1929
+ try { return decodeURIComponent(v.trim()) } catch { return v.trim() }
1930
+ }).filter(Boolean)
1931
+ if (!values.length) return null
1932
+ return { key, values, exclude }
1864
1933
  }
1865
1934
 
1866
1935
  export function getCellFilterValue(resource: any, column: string, kind: string): string {
@@ -1,4 +1,4 @@
1
- import { useState, useRef } from 'react'
1
+ import { useEffect, useState, useRef } from 'react'
2
2
  import {
3
3
  RefreshCw,
4
4
  Terminal,
@@ -18,6 +18,7 @@ import {
18
18
  } from 'lucide-react'
19
19
  import { createTwoFilesPatch } from 'diff'
20
20
  import { clsx } from 'clsx'
21
+ import { classifyDiffLine } from './UnifiedDiff'
21
22
  import { Tooltip } from '../ui/Tooltip'
22
23
  import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDeleteConfirmDialog'
23
24
  import { ConfirmDialog } from '../ui/ConfirmDialog'
@@ -847,6 +848,35 @@ function ArgoActions({ resource, data, onSync, isSyncing, onRefresh, isRefreshin
847
848
  // REVISION HISTORY DIALOG
848
849
  // ============================================================================
849
850
 
851
+ function RevisionImage({ image, displayImage }: { image: string; displayImage: string }) {
852
+ const ref = useRef<HTMLSpanElement>(null)
853
+ const [isTruncated, setIsTruncated] = useState(false)
854
+
855
+ useEffect(() => {
856
+ const element = ref.current
857
+ if (!element) return
858
+
859
+ const measure = () => setIsTruncated(element.scrollWidth > element.clientWidth)
860
+ measure()
861
+
862
+ const observer = new ResizeObserver(measure)
863
+ observer.observe(element)
864
+ return () => observer.disconnect()
865
+ }, [displayImage])
866
+
867
+ return (
868
+ <Tooltip
869
+ content={image}
870
+ delay={300}
871
+ disabled={!isTruncated}
872
+ preserveWrapperWhenDisabled
873
+ wrapperClassName="w-full min-w-0"
874
+ >
875
+ <span ref={ref} className="block truncate">{displayImage}</span>
876
+ </Tooltip>
877
+ )
878
+ }
879
+
850
880
  export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, revisions, isLoading, error, onRollback, isRollingBack }: {
851
881
  kind: string
852
882
  namespace: string
@@ -907,10 +937,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
907
937
  open={open}
908
938
  onClose={handleClose}
909
939
  closable={!isRollingBack}
910
- className={clsx(
911
- "flex flex-col",
912
- diffRevision ? "max-w-5xl w-full max-h-[85vh]" : "max-w-lg w-full"
913
- )}
940
+ className="flex max-h-[85vh] w-[calc(100vw-2rem)] max-w-5xl flex-col"
914
941
  >
915
942
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
916
943
  <div className="flex items-center gap-2">
@@ -923,17 +950,20 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
923
950
  </span>
924
951
  )}
925
952
  </div>
926
- <button
927
- onClick={handleClose}
928
- disabled={isRollingBack}
929
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
930
- >
931
- <X className="w-5 h-5" />
932
- </button>
953
+ <Tooltip content="Close" delay={150}>
954
+ <button
955
+ onClick={handleClose}
956
+ disabled={isRollingBack}
957
+ aria-label="Close revision history"
958
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
959
+ >
960
+ <X className="w-5 h-5" />
961
+ </button>
962
+ </Tooltip>
933
963
  </div>
934
964
 
935
965
  <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
936
- <div className={clsx("p-4 overflow-y-auto", diffRevision ? "max-h-48 shrink-0" : "max-h-80")}>
966
+ <div className={clsx("min-h-0 overflow-x-hidden overflow-y-auto p-4", diffRevision ? "max-h-48 shrink-0" : "max-h-[65vh]")}>
937
967
  {isLoading && (
938
968
  <div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
939
969
  Loading revisions…
@@ -953,7 +983,13 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
953
983
  )}
954
984
 
955
985
  {revisions && revisions.length > 0 && (
956
- <table className="w-full text-sm">
986
+ <table className="w-full table-fixed text-sm">
987
+ <colgroup>
988
+ <col className="w-16" />
989
+ <col />
990
+ <col className="w-24" />
991
+ <col className="w-44" />
992
+ </colgroup>
957
993
  <thead>
958
994
  <tr className="text-theme-text-secondary text-left text-xs uppercase tracking-wider">
959
995
  <th className="pb-2 pr-3 font-medium">Rev</th>
@@ -974,17 +1010,15 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
974
1010
  <td className="py-2 pr-3 text-theme-text-primary font-mono">
975
1011
  #{rev.number}
976
1012
  </td>
977
- <td className="py-2 pr-3 text-theme-text-secondary font-mono max-w-[180px]">
978
- <Tooltip content={rev.image} delay={300} wrapperClassName="min-w-0">
979
- <span className="truncate">{getImageTag(rev.image)}</span>
980
- </Tooltip>
1013
+ <td className="min-w-0 py-2 pr-3 text-theme-text-secondary font-mono">
1014
+ <RevisionImage image={rev.image} displayImage={getImageTag(rev.image)} />
981
1015
  </td>
982
1016
  <td className="py-2 pr-3 text-theme-text-secondary whitespace-nowrap">
983
1017
  {formatTimeAgo(rev.createdAt)}
984
1018
  </td>
985
1019
  <td className="py-2 text-right">
986
1020
  <div className="flex items-center gap-1 justify-end">
987
- {!rev.isCurrent && rev.template && currentRevision?.template && (
1021
+ {!rev.isCurrent && confirmRevision !== rev.number && rev.template && currentRevision?.template && (
988
1022
  <Tooltip content="Compare with current revision" delay={150}>
989
1023
  <button
990
1024
  onClick={() => setDiffRevision(diffRevision === rev.number ? null : rev.number)}
@@ -1124,9 +1158,7 @@ function RevisionDiffView({ currentTemplate, selectedTemplate, currentRevision,
1124
1158
  {hasChanges ? (
1125
1159
  <pre className="text-xs font-mono p-0 m-0">
1126
1160
  {diffLines.map((line, index) => {
1127
- const isAddition = line.startsWith('+') && !line.startsWith('+++')
1128
- const isDeletion = line.startsWith('-') && !line.startsWith('---')
1129
- const isHeader = line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')
1161
+ const { isAddition, isRemoval: isDeletion, isHeader } = classifyDiffLine(line)
1130
1162
 
1131
1163
  return (
1132
1164
  <div
@@ -0,0 +1,51 @@
1
+ import { clsx } from 'clsx'
2
+
3
+ // Shared unified-diff rendering primitives. Every diff surface (Helm manifest
4
+ // diff, Helm values preview, revision history, Argo resource diff) classifies
5
+ // lines by the same leading marker; keeping that logic here stops the four
6
+ // renderers from silently drifting apart.
7
+
8
+ // classifyDiffLine tags a unified-diff line as an addition, a removal, or a hunk/
9
+ // file header. `+++`/`---` file headers are NOT additions/removals.
10
+ export function classifyDiffLine(line: string): {
11
+ isAddition: boolean
12
+ isRemoval: boolean
13
+ isHeader: boolean
14
+ } {
15
+ return {
16
+ isAddition: line.startsWith('+') && !line.startsWith('+++'),
17
+ isRemoval: line.startsWith('-') && !line.startsWith('---'),
18
+ isHeader: line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++'),
19
+ }
20
+ }
21
+
22
+ // hasDiffBodyChange is true when a unified-diff string has any added/removed body
23
+ // line (ignoring the patch's own file/hunk headers) — the "any real change?"
24
+ // short-circuit shared by the manifest and Argo diff surfaces.
25
+ export function hasDiffBodyChange(diff: string): boolean {
26
+ return diff.split('\n').some((line) => {
27
+ const { isAddition, isRemoval } = classifyDiffLine(line)
28
+ return isAddition || isRemoval
29
+ })
30
+ }
31
+
32
+ // DiffLine renders one unified-diff line colored by marker — the gutter-less
33
+ // "manifest" style shared by the Helm manifest diff and the Argo resource diff.
34
+ // (The line-numbered gutter variants build their own row but share
35
+ // classifyDiffLine.)
36
+ export function DiffLine({ line }: { line: string }) {
37
+ const { isAddition, isRemoval, isHeader } = classifyDiffLine(line)
38
+ return (
39
+ <div
40
+ className={clsx(
41
+ 'whitespace-pre',
42
+ isAddition && 'bg-green-500/10 text-green-700 dark:text-green-400',
43
+ isRemoval && 'bg-red-500/10 text-red-700 dark:text-red-400',
44
+ isHeader && 'text-theme-text-tertiary font-bold',
45
+ !isAddition && !isRemoval && !isHeader && 'text-theme-text-secondary'
46
+ )}
47
+ >
48
+ {line || ' '}
49
+ </div>
50
+ )
51
+ }
@@ -4,3 +4,4 @@ export { ResourceActionsBar, RevisionHistoryDialog } from './ResourceActionsBar'
4
4
  export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
5
5
  export { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from './ManagedByChip'
6
6
  export { DetailShell, type DetailShellProps, type DetailShellTab } from './DetailShell'
7
+ export { classifyDiffLine, hasDiffBodyChange, DiffLine } from './UnifiedDiff'
@@ -98,6 +98,10 @@ export interface TimelineListProps {
98
98
  // returned set reaches it, the list surfaces an end-of-list note so the drop of
99
99
  // older events isn't silent. Omit when the source isn't capped (e.g. compact).
100
100
  truncatedAt?: number
101
+ // Hosts that filter a capped source after fetching must report truncation
102
+ // explicitly because the visible row count no longer reflects the source cap.
103
+ isTruncated?: boolean
104
+ truncationMessage?: string
101
105
  }
102
106
 
103
107
  const TIME_RANGES: { value: TimeRange; label: string }[] = [
@@ -109,7 +113,7 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
109
113
  { value: 'all', label: 'All' },
110
114
  ]
111
115
 
112
- export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange, rangeOptions = TIME_RANGES, hideRangeSelector = false, showDeleted: showDeletedProp, onShowDeletedChange, search: searchProp, onSearchChange, activityFilter: activityFilterProp, onActivityFilterChange, kindFilter: kindFilterProp, onKindFilterChange, onVisibleWindowChange, scrollToMs, compact = false, selectedEventId, onSelectEvent, truncatedAt }: TimelineListProps) {
116
+ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange, rangeOptions = TIME_RANGES, hideRangeSelector = false, showDeleted: showDeletedProp, onShowDeletedChange, search: searchProp, onSearchChange, activityFilter: activityFilterProp, onActivityFilterChange, kindFilter: kindFilterProp, onKindFilterChange, onVisibleWindowChange, scrollToMs, compact = false, selectedEventId, onSelectEvent, truncatedAt, isTruncated, truncationMessage }: TimelineListProps) {
113
117
  const [searchInternal, setSearchInternal] = useState('')
114
118
  const searchTerm = searchProp ?? searchInternal
115
119
  const setSearchTerm = onSearchChange ?? setSearchInternal
@@ -510,10 +514,10 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
510
514
  </div>
511
515
  </div>
512
516
  ))}
513
- {truncatedAt != null && events.length >= truncatedAt && (
517
+ {truncatedAt != null && (isTruncated ?? events.length >= truncatedAt) && (
514
518
  <div className="flex items-center justify-center gap-2 py-4 text-sm text-theme-text-tertiary">
515
519
  <Clock className="h-4 w-4 opacity-50" />
516
- <span>Showing the newest {truncatedAt.toLocaleString()} events in this range — narrow the query to see older ones</span>
520
+ <span>{truncationMessage ?? `Showing the newest ${truncatedAt.toLocaleString()} events in this range — narrow the query to see older ones`}</span>
517
521
  </div>
518
522
  )}
519
523
  </div>
@@ -35,6 +35,30 @@ describe('TimelineList controlled vs fallback', () => {
35
35
  expect(html).toContain('K8s Events')
36
36
  expect(html).toContain('Problems')
37
37
  })
38
+
39
+ it('shows an explicit truncation message after host-side filtering', () => {
40
+ const html = renderToString(
41
+ <TimelineList
42
+ events={EVENTS}
43
+ isLoading={false}
44
+ truncatedAt={10_000}
45
+ isTruncated
46
+ truncationMessage="Only the newest source window was searched."
47
+ />,
48
+ )
49
+ expect(html).toContain('Only the newest source window was searched.')
50
+ })
51
+
52
+ it('does not infer truncation when the host explicitly reports a complete source', () => {
53
+ const events = Array.from({ length: 2 }, (_, index) => ({
54
+ ...EVENTS[0],
55
+ id: String(index),
56
+ }))
57
+ const html = renderToString(
58
+ <TimelineList events={events} isLoading={false} truncatedAt={2} isTruncated={false} />,
59
+ )
60
+ expect(html).not.toContain('Showing the newest 2 events')
61
+ })
38
62
  })
39
63
 
40
64
  describe('TimelineSwimlanes controlled vs fallback', () => {
@@ -0,0 +1,30 @@
1
+ interface TopologyBreadcrumbProps {
2
+ /** The single active namespace to show. */
3
+ namespace: string
4
+ /** When set, renders a clickable "All Namespaces" crumb that clears the filter. */
5
+ onClear?: () => void
6
+ }
7
+
8
+ /**
9
+ * Namespace-context crumb for the topology overlay bar, shown when the graph is
10
+ * scoped to a single namespace. Place it in the host's overlay-bar composition
11
+ * (e.g. above the search) so it reads as page context.
12
+ */
13
+ export function TopologyBreadcrumb({ namespace, onClear }: TopologyBreadcrumbProps) {
14
+ return (
15
+ <div className="flex items-center gap-1.5 pointer-events-auto">
16
+ {onClear && (
17
+ <button
18
+ onClick={onClear}
19
+ className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
20
+ >
21
+ All Namespaces
22
+ </button>
23
+ )}
24
+ {onClear && <span className="text-xs text-theme-text-tertiary">/</span>}
25
+ <span className="text-xs font-medium text-theme-text-secondary bg-theme-surface/80 backdrop-blur-sm border border-theme-border/50 rounded-md px-2 py-0.5">
26
+ {namespace}
27
+ </span>
28
+ </div>
29
+ )
30
+ }
@@ -80,7 +80,9 @@ export function TopologyControls({
80
80
  }
81
81
 
82
82
  return (
83
- <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
83
+ // Layout-neutral (positioned by the host's overlay bar): `relative` anchors
84
+ // its own dropdowns; `pointer-events-auto` inside the pointer-events-none bar.
85
+ <div className="relative flex items-center gap-2 pointer-events-auto">
84
86
  {/* Freshness/liveness status — backed for legibility over the canvas but
85
87
  borderless + divided off, so it reads as a status, not another control. */}
86
88
  {leadingSlot && (