@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.
- package/package.json +4 -4
- package/src/components/applications/ApplicationDetail.test.tsx +136 -8
- package/src/components/applications/ApplicationDetail.tsx +1215 -487
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
- package/src/components/gitops/GitOpsTableView.tsx +10 -3
- package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
- package/src/components/gitops/insights/index.ts +1 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
- package/src/components/gitops/insights/insights-helpers.ts +5 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
- package/src/components/issues/IssuesView.tsx +55 -29
- package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
- package/src/components/issues/ResourceIssuesSection.tsx +21 -1
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.tsx +109 -19
- package/src/components/resources/column-filter-serialization.test.ts +79 -1
- package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
- package/src/components/resources/resource-utils-argo.test.ts +18 -1
- package/src/components/resources/resource-utils-argo.ts +8 -0
- package/src/components/resources/resource-utils.ts +94 -25
- package/src/components/shared/ResourceActionsBar.tsx +54 -22
- package/src/components/shared/UnifiedDiff.tsx +51 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +7 -3
- package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
- package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
- package/src/components/topology/TopologyControls.tsx +3 -1
- package/src/components/topology/TopologyGraph.tsx +76 -90
- package/src/components/topology/TopologyOverlayBar.tsx +30 -0
- package/src/components/topology/TopologySearch.tsx +23 -15
- package/src/components/topology/index.ts +2 -0
- package/src/components/ui/Badge.tsx +1 -1
- package/src/components/ui/SelectMenu.tsx +81 -0
- package/src/components/ui/Tooltip.test.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +9 -12
- package/src/components/ui/drawer-components.tsx +9 -6
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +24 -3
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +1 -0
- package/src/types/gitops-insights.ts +69 -1
- package/src/types/gitops.ts +3 -3
- package/src/utils/api-resources.test.ts +36 -1
- package/src/utils/api-resources.ts +16 -0
- package/src/utils/application-history.test.ts +538 -0
- package/src/utils/application-history.ts +257 -0
- package/src/utils/applications.test.ts +34 -0
- package/src/utils/applications.ts +25 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/resource-hierarchy.test.ts +35 -1
- 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
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
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
|
-
|
|
1835
|
-
|
|
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
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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.
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
|
|
1859
|
-
|
|
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]) =>
|
|
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
|
-
|
|
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=
|
|
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
|
-
<
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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("
|
|
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
|
|
978
|
-
<
|
|
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 =
|
|
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
|
-
|
|
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 && (
|