@skyhook-io/k8s-ui 1.7.15 → 1.8.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.
- package/package.json +2 -1
- package/src/components/applications/ApplicationsList.tsx +131 -115
- package/src/components/applications/index.ts +3 -1
- package/src/components/audit/AuditCard.tsx +1 -1
- package/src/components/charts/AreaChart.tsx +52 -18
- package/src/components/charts/MetricsSummary.tsx +12 -3
- package/src/components/charts/saturation.test.ts +34 -0
- package/src/components/charts/saturation.ts +1 -0
- package/src/components/charts/types.ts +5 -4
- package/src/components/checks/ChecksView.tsx +14 -25
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +307 -280
- package/src/components/gitops/detail-helpers.test.ts +1 -1
- package/src/components/issues/severity.ts +19 -11
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +13 -0
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
- package/src/components/resources/renderers/HPARenderer.tsx +93 -86
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
- package/src/components/resources/resource-utils-hpa.test.ts +30 -0
- package/src/components/resources/resource-utils-hpa.ts +106 -0
- package/src/components/resources/resource-utils.ts +6 -10
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- package/src/components/timeline/TimelineList.tsx +31 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +27 -5
- package/src/components/ui/DistributionBar.tsx +88 -0
- package/src/components/ui/Facet.tsx +130 -0
- package/src/components/ui/PageHeader.tsx +43 -0
- package/src/components/ui/SearchPillInput.tsx +278 -0
- package/src/components/ui/SortableTh.tsx +58 -0
- package/src/components/ui/SummaryTile.tsx +60 -0
- package/src/components/ui/drawer-components.tsx +56 -17
- package/src/components/ui/index.ts +12 -0
- package/src/components/workload/WorkloadView.tsx +18 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +7 -3
- package/src/types/core.ts +54 -2
- package/src/utils/rbac-blast-radius.test.ts +48 -3
- package/src/utils/rbac-blast-radius.ts +14 -4
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { getHPATableState, hpaStateLabel } from './resource-utils-hpa'
|
|
5
|
+
import type { HPADiagnosisState } from '../../types'
|
|
6
|
+
|
|
7
|
+
interface FixtureCase {
|
|
8
|
+
name: string
|
|
9
|
+
hpa: unknown
|
|
10
|
+
expectedTableState: HPADiagnosisState
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('getHPATableState', () => {
|
|
14
|
+
for (const tc of loadCases()) {
|
|
15
|
+
it(`classifies ${tc.name}`, () => {
|
|
16
|
+
expect(getHPATableState(tc.hpa)).toBe(tc.expectedTableState)
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('hpaStateLabel', () => {
|
|
22
|
+
it('keeps max-bound wording terse for table cells', () => {
|
|
23
|
+
expect(hpaStateLabel('limited_max')).toBe('Maxed')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function loadCases(): FixtureCase[] {
|
|
28
|
+
const path = resolve(process.cwd(), '../../testdata/hpa-diagnosis/cases.json')
|
|
29
|
+
return JSON.parse(readFileSync(path, 'utf8')) as FixtureCase[]
|
|
30
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { HealthLevel, StatusBadge } from './resource-utils'
|
|
2
|
+
import type { HPADiagnosisState } from '../../types'
|
|
3
|
+
|
|
4
|
+
export function getHPATableState(hpa: any): HPADiagnosisState {
|
|
5
|
+
// List rows only classify states worth scanning in a table; the Go diagnosis powers the full drawer context.
|
|
6
|
+
const conditions = Array.isArray(hpa?.status?.conditions) ? hpa.status.conditions : []
|
|
7
|
+
const current = hpa?.status?.currentReplicas ?? 0
|
|
8
|
+
const desired = hpa?.status?.desiredReplicas ?? 0
|
|
9
|
+
const min = hpa?.spec?.minReplicas ?? 1
|
|
10
|
+
const max = hpa?.spec?.maxReplicas ?? 0
|
|
11
|
+
|
|
12
|
+
if (conditionStatus(conditions, 'AbleToScale') === 'False') return 'unable_to_scale'
|
|
13
|
+
const scalingActive = condition(conditions, 'ScalingActive')
|
|
14
|
+
if (scalingActive?.status === 'False') {
|
|
15
|
+
if (isScalingDisabled(scalingActive)) return 'disabled'
|
|
16
|
+
return 'metrics_unavailable'
|
|
17
|
+
}
|
|
18
|
+
if (max > 0 && min === max && current === desired && desired === max) return 'pinned'
|
|
19
|
+
if (isMaxLimited(condition(conditions, 'ScalingLimited'))) return 'limited_max'
|
|
20
|
+
|
|
21
|
+
if (current < desired) return 'scaling_up'
|
|
22
|
+
if (current > desired) return 'scaling_down'
|
|
23
|
+
return 'ok'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hpaStateLabel(state: HPADiagnosisState): string {
|
|
27
|
+
switch (state) {
|
|
28
|
+
case 'unable_to_scale':
|
|
29
|
+
return 'Unable to scale'
|
|
30
|
+
case 'metrics_unavailable':
|
|
31
|
+
return 'Metrics unavailable'
|
|
32
|
+
case 'metrics_incomplete':
|
|
33
|
+
return 'Metrics incomplete'
|
|
34
|
+
case 'disabled':
|
|
35
|
+
return 'Disabled'
|
|
36
|
+
case 'pinned':
|
|
37
|
+
return 'Pinned'
|
|
38
|
+
case 'limited_max':
|
|
39
|
+
return 'Maxed'
|
|
40
|
+
case 'limited_min':
|
|
41
|
+
return 'At min'
|
|
42
|
+
case 'stale':
|
|
43
|
+
return 'Stale'
|
|
44
|
+
case 'stabilized':
|
|
45
|
+
return 'Stabilized'
|
|
46
|
+
case 'scaling_up':
|
|
47
|
+
return 'Scaling Up'
|
|
48
|
+
case 'scaling_down':
|
|
49
|
+
return 'Scaling Down'
|
|
50
|
+
case 'ok':
|
|
51
|
+
return 'Stable'
|
|
52
|
+
default:
|
|
53
|
+
return 'Unknown'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hpaStateLevel(state: HPADiagnosisState): HealthLevel {
|
|
58
|
+
switch (state) {
|
|
59
|
+
case 'unable_to_scale':
|
|
60
|
+
case 'metrics_unavailable':
|
|
61
|
+
return 'unhealthy'
|
|
62
|
+
case 'limited_max':
|
|
63
|
+
case 'metrics_incomplete':
|
|
64
|
+
return 'degraded'
|
|
65
|
+
case 'scaling_up':
|
|
66
|
+
case 'scaling_down':
|
|
67
|
+
return 'degraded'
|
|
68
|
+
case 'limited_min':
|
|
69
|
+
case 'disabled':
|
|
70
|
+
case 'pinned':
|
|
71
|
+
case 'stabilized':
|
|
72
|
+
return 'neutral'
|
|
73
|
+
case 'stale':
|
|
74
|
+
return 'unknown'
|
|
75
|
+
case 'ok':
|
|
76
|
+
return 'healthy'
|
|
77
|
+
default:
|
|
78
|
+
return 'unknown'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function hpaStatusFromState(state: HPADiagnosisState, healthColors: Record<HealthLevel, string>): StatusBadge {
|
|
83
|
+
const level = hpaStateLevel(state)
|
|
84
|
+
return { text: hpaStateLabel(state), color: healthColors[level], level }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function conditionStatus(conditions: any[], type: string): string | undefined {
|
|
88
|
+
return condition(conditions, type)?.status
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function condition(conditions: any[], type: string): any | undefined {
|
|
92
|
+
return conditions.find((item) => item?.type === type)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isScalingDisabled(item: any): boolean {
|
|
96
|
+
const reason = String(item?.reason ?? '').toLowerCase()
|
|
97
|
+
const message = String(item?.message ?? '').toLowerCase()
|
|
98
|
+
return reason === 'scalingdisabled' || message.includes('scaling is disabled')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isMaxLimited(item: any): boolean {
|
|
102
|
+
if (item?.status !== 'True') return false
|
|
103
|
+
const reason = String(item?.reason ?? '').toLowerCase()
|
|
104
|
+
const message = String(item?.message ?? '').toLowerCase()
|
|
105
|
+
return reason.includes('toomany') || message.includes('maximum')
|
|
106
|
+
}
|
|
@@ -14,6 +14,7 @@ import { getResourceClaimStatus as _getResourceClaimStatus, getResourceClaimDevi
|
|
|
14
14
|
import { getNvidiaClusterPolicyStatus as _getNvidiaClusterPolicyStatus, getNvidiaClusterPolicyEnabledComponents as _getNvidiaClusterPolicyEnabledComponents, getNvidiaDriverStatus as _getNvidiaDriverStatus } from './resource-utils-nvidia'
|
|
15
15
|
import { getBackupStatus as _getBackupStatus, getRestoreStatus as _getRestoreStatus, getScheduleStatus as _getScheduleStatus, getBSLStatus as _getBSLStatus } from './resource-utils-velero'
|
|
16
16
|
import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternalSecretStatus as _getClusterExternalSecretStatus, getSecretStoreStatus as _getSecretStoreStatus, getClusterSecretStoreStatus as _getClusterSecretStoreStatus, getSecretStoreProviderType as _getSecretStoreProviderType } from './resource-utils-eso'
|
|
17
|
+
import { getHPATableState, hpaStatusFromState } from './resource-utils-hpa'
|
|
17
18
|
|
|
18
19
|
// ============================================================================
|
|
19
20
|
// STATUS & HEALTH UTILITIES
|
|
@@ -960,16 +961,7 @@ export function getCronJobLastRun(cj: any): string | null {
|
|
|
960
961
|
// ============================================================================
|
|
961
962
|
|
|
962
963
|
export function getHPAStatus(hpa: any): StatusBadge {
|
|
963
|
-
|
|
964
|
-
const desired = hpa.status?.desiredReplicas || 0
|
|
965
|
-
|
|
966
|
-
if (current === desired) {
|
|
967
|
-
return { text: 'Stable', color: healthColors.healthy, level: 'healthy' }
|
|
968
|
-
}
|
|
969
|
-
if (current < desired) {
|
|
970
|
-
return { text: 'Scaling Up', color: healthColors.degraded, level: 'degraded' }
|
|
971
|
-
}
|
|
972
|
-
return { text: 'Scaling Down', color: healthColors.degraded, level: 'degraded' }
|
|
964
|
+
return hpaStatusFromState(getHPATableState(hpa), healthColors)
|
|
973
965
|
}
|
|
974
966
|
|
|
975
967
|
export function getHPAReplicas(hpa: any): { current: number; min: number; max: number } {
|
|
@@ -1135,6 +1127,10 @@ export function cronToHuman(cron: string): string {
|
|
|
1135
1127
|
if (minute === '0' && hour === '0' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1136
1128
|
return 'Daily at midnight'
|
|
1137
1129
|
}
|
|
1130
|
+
if (minute === '0' && hour.startsWith('*/') && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1131
|
+
const interval = hour.slice(2)
|
|
1132
|
+
return interval === '1' ? 'Every hour' : `Every ${interval} hours`
|
|
1133
|
+
}
|
|
1138
1134
|
if (minute === '0' && hour !== '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1139
1135
|
return `Daily at ${hour}:00`
|
|
1140
1136
|
}
|
|
@@ -226,10 +226,11 @@ import {
|
|
|
226
226
|
isComposite,
|
|
227
227
|
isClaim,
|
|
228
228
|
} from '../resources/resource-utils-crossplane'
|
|
229
|
-
import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent } from '../../types'
|
|
229
|
+
import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent, HPADiagnosis } from '../../types'
|
|
230
230
|
import type { CopyHandler } from '../ui/drawer-components'
|
|
231
231
|
import { AlertBanner } from '../ui/drawer-components'
|
|
232
232
|
import { replicaScalers } from '../../utils/replica-scalers'
|
|
233
|
+
import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
|
|
233
234
|
|
|
234
235
|
/**
|
|
235
236
|
* Override map letting each platform consumer swap in its own renderer components.
|
|
@@ -258,6 +259,7 @@ export interface RendererOverrides {
|
|
|
258
259
|
onNavigate?: (ref: ResourceRef) => void
|
|
259
260
|
relationships?: Relationships
|
|
260
261
|
scaleBlockedBy?: ResourceRef[]
|
|
262
|
+
scalerDiagnostics?: ScalerDiagnosis[]
|
|
261
263
|
}>
|
|
262
264
|
// Optional override for Crossplane Composite / Claim — host wraps the
|
|
263
265
|
// package renderer to fan out per-composed-ref status fetches via React Query.
|
|
@@ -297,6 +299,7 @@ export interface RendererOverrides {
|
|
|
297
299
|
HPARenderer?: React.ComponentType<{
|
|
298
300
|
data: any
|
|
299
301
|
onNavigate?: (ref: ResourceRef) => void
|
|
302
|
+
hpaDiagnosis?: HPADiagnosis
|
|
300
303
|
}>
|
|
301
304
|
// PVC: host wraps the base renderer to add a kubelet-derived usage gauge
|
|
302
305
|
// when Prometheus is scraping kubelet endpoints.
|
|
@@ -376,6 +379,8 @@ interface ResourceRendererDispatchProps {
|
|
|
376
379
|
data: any
|
|
377
380
|
relationships?: Relationships
|
|
378
381
|
certificateInfo?: SecretCertificateInfo
|
|
382
|
+
hpaDiagnosis?: HPADiagnosis
|
|
383
|
+
scalerDiagnostics?: ScalerDiagnosis[]
|
|
379
384
|
onCopy: (text: string, key: string) => void
|
|
380
385
|
copied: string | null
|
|
381
386
|
onNavigate?: (ref: ResourceRef) => void
|
|
@@ -417,6 +422,8 @@ export function ResourceRendererDispatch({
|
|
|
417
422
|
data,
|
|
418
423
|
relationships,
|
|
419
424
|
certificateInfo,
|
|
425
|
+
hpaDiagnosis,
|
|
426
|
+
scalerDiagnostics,
|
|
420
427
|
onCopy,
|
|
421
428
|
copied,
|
|
422
429
|
onNavigate,
|
|
@@ -503,6 +510,7 @@ export function ResourceRendererDispatch({
|
|
|
503
510
|
onNavigate={onNavigate}
|
|
504
511
|
relationships={relationships}
|
|
505
512
|
scaleBlockedBy={scaleBlockedBy}
|
|
513
|
+
scalerDiagnostics={scalerDiagnostics}
|
|
506
514
|
/>
|
|
507
515
|
)}
|
|
508
516
|
{kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
|
|
@@ -513,7 +521,7 @@ export function ResourceRendererDispatch({
|
|
|
513
521
|
{kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
|
|
514
522
|
{kind === 'jobs' && <JobRenderer data={data} />}
|
|
515
523
|
{kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
|
|
516
|
-
{(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} />}
|
|
524
|
+
{(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} hpaDiagnosis={hpaDiagnosis} />}
|
|
517
525
|
{kind === 'nodes' && <NodeComp data={data} relationships={relationships} />}
|
|
518
526
|
{kind === 'persistentvolumeclaims' && <PVCComp data={data} onNavigate={onNavigate} />}
|
|
519
527
|
{kind === 'rollouts' && <RolloutRenderer data={data} />}
|
|
@@ -53,6 +53,11 @@ export interface TimelineListProps {
|
|
|
53
53
|
onResourceClick?: NavigateToResource
|
|
54
54
|
initialFilter?: ActivityTypeFilter
|
|
55
55
|
initialTimeRange?: TimeRange
|
|
56
|
+
// Controlled "show deleted" toggle. When omitted the component manages it
|
|
57
|
+
// internally; the host passes it to share one toggle across list + swimlane
|
|
58
|
+
// and to drive server-side delete filtering.
|
|
59
|
+
showDeleted?: boolean
|
|
60
|
+
onShowDeletedChange?: (showDeleted: boolean) => void
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
|
@@ -80,11 +85,14 @@ const RESOURCE_KINDS = [
|
|
|
80
85
|
'StatefulSet',
|
|
81
86
|
]
|
|
82
87
|
|
|
83
|
-
export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange }: TimelineListProps) {
|
|
88
|
+
export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange, showDeleted: showDeletedProp, onShowDeletedChange }: TimelineListProps) {
|
|
84
89
|
const [searchTerm, setSearchTerm] = useState('')
|
|
85
90
|
const [activityTypeFilter, setActivityTypeFilter] = useState<ActivityTypeFilter>(initialFilter ?? 'all')
|
|
86
91
|
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange ?? '1h')
|
|
87
92
|
const [kindFilter, setKindFilter] = useState<string>('')
|
|
93
|
+
const [showDeletedInternal, setShowDeletedInternal] = useState(true)
|
|
94
|
+
const showDeleted = showDeletedProp ?? showDeletedInternal
|
|
95
|
+
const setShowDeleted = onShowDeletedChange ?? setShowDeletedInternal
|
|
88
96
|
const [expandedItem, setExpandedItem] = useState<string | null>(null)
|
|
89
97
|
|
|
90
98
|
useEffect(() => {
|
|
@@ -111,6 +119,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
111
119
|
const isUnhealthyChange = isChangeEvent(item) && (item.healthState === 'unhealthy' || item.healthState === 'degraded')
|
|
112
120
|
if (!isUnhealthyChange) return false
|
|
113
121
|
}
|
|
122
|
+
if (!showDeleted && item.eventType === 'delete') return false
|
|
114
123
|
|
|
115
124
|
// Filter by search term
|
|
116
125
|
if (searchTerm) {
|
|
@@ -129,7 +138,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
129
138
|
|
|
130
139
|
return true
|
|
131
140
|
})
|
|
132
|
-
}, [events, activityTypeFilter, searchTerm])
|
|
141
|
+
}, [events, activityTypeFilter, searchTerm, showDeleted])
|
|
133
142
|
|
|
134
143
|
// Aggregated event group type
|
|
135
144
|
type AggregatedItem = {
|
|
@@ -245,12 +254,13 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
245
254
|
|
|
246
255
|
// Count stats
|
|
247
256
|
const stats = useMemo(() => {
|
|
248
|
-
if (!events) return { total: 0, changes: 0, warnings: 0, unhealthy: 0 }
|
|
257
|
+
if (!events) return { total: 0, changes: 0, warnings: 0, unhealthy: 0, deleted: 0 }
|
|
249
258
|
return {
|
|
250
259
|
total: events.length,
|
|
251
260
|
changes: events.filter((e) => isChangeEvent(e)).length,
|
|
252
261
|
warnings: events.filter((e) => e.eventType === 'Warning').length,
|
|
253
262
|
unhealthy: events.filter((e) => isChangeEvent(e) && (e.healthState === 'unhealthy' || e.healthState === 'degraded')).length,
|
|
263
|
+
deleted: events.filter((e) => e.eventType === 'delete').length,
|
|
254
264
|
}
|
|
255
265
|
}, [events])
|
|
256
266
|
|
|
@@ -306,6 +316,24 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
306
316
|
/>
|
|
307
317
|
</div>
|
|
308
318
|
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
onClick={() => setShowDeleted(!showDeleted)}
|
|
322
|
+
title="Show or hide resources that were deleted, including Pods that no longer exist"
|
|
323
|
+
className={clsx(
|
|
324
|
+
'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-2 bg-theme-elevated',
|
|
325
|
+
showDeleted ? 'text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
326
|
+
)}
|
|
327
|
+
>
|
|
328
|
+
<Trash2 className="w-3 h-3" />
|
|
329
|
+
Deleted
|
|
330
|
+
{stats.deleted > 0 && (
|
|
331
|
+
<span className="text-xs px-1.5 rounded bg-theme-hover/50">
|
|
332
|
+
{stats.deleted}
|
|
333
|
+
</span>
|
|
334
|
+
)}
|
|
335
|
+
</button>
|
|
336
|
+
|
|
309
337
|
{/* Kind filter */}
|
|
310
338
|
<select
|
|
311
339
|
value={kindFilter}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
Timer,
|
|
22
22
|
RotateCcw,
|
|
23
23
|
Shield,
|
|
24
|
+
Trash2,
|
|
24
25
|
} from 'lucide-react'
|
|
25
26
|
import type { TimelineEvent, Topology } from '../../types'
|
|
26
27
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
@@ -54,9 +55,14 @@ export interface TimelineSwimlanesProps {
|
|
|
54
55
|
// RBAC capability flag (was a radar/web context); host passes it. Default false.
|
|
55
56
|
hasLimitedAccess?: boolean
|
|
56
57
|
// GitOps lane labels deep-link to a controller path; the host decides how to
|
|
57
|
-
// navigate (radar router push, or cross-
|
|
58
|
+
// navigate (radar router push, or cross-route-tree href). When omitted, GitOps lanes
|
|
58
59
|
// fall back to onResourceClick (the resource drawer).
|
|
59
60
|
onNavigatePath?: (path: string) => void
|
|
61
|
+
// Controlled "show deleted" toggle. When omitted the component manages it
|
|
62
|
+
// internally; the host passes it to share one toggle with the list view and
|
|
63
|
+
// to drive server-side delete filtering on the underlying fetch.
|
|
64
|
+
showDeleted?: boolean
|
|
65
|
+
onShowDeletedChange?: (showDeleted: boolean) => void
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
interface ResourceLane extends BaseResourceLane {
|
|
@@ -180,7 +186,7 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
|
|
|
180
186
|
return breakdown
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath }: TimelineSwimlanesProps) {
|
|
189
|
+
export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath, showDeleted: showDeletedProp, onShowDeletedChange }: TimelineSwimlanesProps) {
|
|
184
190
|
// Timeline lane labels for GitOps CRs (Application/Kustomization/HelmRelease)
|
|
185
191
|
// deep-link to GitOps detail rather than the resource drawer — the lane is
|
|
186
192
|
// already telling the user "this controller had changes/events"; the GitOps
|
|
@@ -203,6 +209,9 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
203
209
|
const [expandedLanes, setExpandedLanes] = useState<Set<string>>(new Set())
|
|
204
210
|
const [hasAutoZoomed, setHasAutoZoomed] = useState(false)
|
|
205
211
|
const [groupByApp, setGroupByApp] = useState(true) // Group by app.kubernetes.io/name label
|
|
212
|
+
const [showDeletedInternal, setShowDeletedInternal] = useState(true)
|
|
213
|
+
const showDeleted = showDeletedProp ?? showDeletedInternal
|
|
214
|
+
const setShowDeleted = onShowDeletedChange ?? setShowDeletedInternal
|
|
206
215
|
|
|
207
216
|
// Stable lane ordering - use ref to avoid render loop (lanes depends on order, order depends on lanes)
|
|
208
217
|
const laneOrderRef = useRef<Map<string, number>>(new Map())
|
|
@@ -253,17 +262,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
253
262
|
|
|
254
263
|
// Filter events by search term
|
|
255
264
|
const filteredEvents = useMemo(() => {
|
|
256
|
-
|
|
265
|
+
const baseEvents = showDeleted ? events : events.filter(e => e.eventType !== 'delete')
|
|
266
|
+
if (!searchTerm) return baseEvents
|
|
257
267
|
|
|
258
268
|
const term = searchTerm.toLowerCase()
|
|
259
|
-
return
|
|
269
|
+
return baseEvents.filter(e =>
|
|
260
270
|
e.name.toLowerCase().includes(term) ||
|
|
261
271
|
e.kind.toLowerCase().includes(term) ||
|
|
262
272
|
e.namespace?.toLowerCase().includes(term) ||
|
|
263
273
|
e.reason?.toLowerCase().includes(term) ||
|
|
264
274
|
e.message?.toLowerCase().includes(term)
|
|
265
275
|
)
|
|
266
|
-
}, [events, searchTerm])
|
|
276
|
+
}, [events, searchTerm, showDeleted])
|
|
267
277
|
|
|
268
278
|
// Build hierarchical lanes using owner references + topology edges
|
|
269
279
|
// Uses the shared utility from utils/resource-hierarchy.ts
|
|
@@ -514,6 +524,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
514
524
|
<span className="border-b border-dotted border-theme-text-tertiary">Group by app</span>
|
|
515
525
|
</label>
|
|
516
526
|
</Tooltip>
|
|
527
|
+
<Tooltip content="Show resources Radar observed being deleted, including Pods that no longer exist" position="bottom">
|
|
528
|
+
<label className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary">
|
|
529
|
+
<input
|
|
530
|
+
type="checkbox"
|
|
531
|
+
checked={showDeleted}
|
|
532
|
+
onChange={(e) => setShowDeleted(e.target.checked)}
|
|
533
|
+
className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-accent focus:ring-accent focus:ring-offset-0"
|
|
534
|
+
/>
|
|
535
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
536
|
+
<span className="border-b border-dotted border-theme-text-tertiary">Deleted</span>
|
|
537
|
+
</label>
|
|
538
|
+
</Tooltip>
|
|
517
539
|
{/* View toggle */}
|
|
518
540
|
{onViewModeChange && (
|
|
519
541
|
<div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { clsx } from 'clsx'
|
|
2
|
+
|
|
3
|
+
export interface DistributionSegment {
|
|
4
|
+
key: string
|
|
5
|
+
count: number
|
|
6
|
+
/** Tailwind background class for this segment's fill (e.g. status-tone bg). */
|
|
7
|
+
fillClass: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A single segmented proportion bar — the shared "distribution of statuses"
|
|
11
|
+
// element behind the Checks severity rollup, the Applications/GitOps health
|
|
12
|
+
// rollup, and the Issues rollup. One height, one track, one radius everywhere.
|
|
13
|
+
//
|
|
14
|
+
// Intentional empty state: with no data (sum === 0) it renders NOTHING rather
|
|
15
|
+
// than a flat grey track that reads as a skeleton/loading bar on sparse clusters.
|
|
16
|
+
export function DistributionBar({
|
|
17
|
+
segments,
|
|
18
|
+
className,
|
|
19
|
+
ariaLabel = 'Distribution',
|
|
20
|
+
}: {
|
|
21
|
+
segments: DistributionSegment[]
|
|
22
|
+
className?: string
|
|
23
|
+
ariaLabel?: string
|
|
24
|
+
}) {
|
|
25
|
+
const sum = segments.reduce((n, s) => n + s.count, 0)
|
|
26
|
+
if (sum === 0) return null
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className={clsx('flex h-1.5 overflow-hidden rounded-full bg-theme-elevated', className)}
|
|
30
|
+
role="img"
|
|
31
|
+
aria-label={ariaLabel}
|
|
32
|
+
>
|
|
33
|
+
{segments.map((s) =>
|
|
34
|
+
s.count > 0 ? (
|
|
35
|
+
<div
|
|
36
|
+
key={s.key}
|
|
37
|
+
className={clsx(s.fillClass, 'transition-[width] duration-500 ease-out')}
|
|
38
|
+
style={{ width: `${(s.count / sum) * 100}%` }}
|
|
39
|
+
/>
|
|
40
|
+
) : null,
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A legend entry for a DistributionBar: a tone dot + count + label. When onClick
|
|
47
|
+
// is provided it's a toggle (the legend doubles as the filter, as in the Checks
|
|
48
|
+
// and Issues queues); without it, it's a static legend item.
|
|
49
|
+
export function DistributionLegendChip({
|
|
50
|
+
label,
|
|
51
|
+
count,
|
|
52
|
+
fillClass,
|
|
53
|
+
textClass,
|
|
54
|
+
active = false,
|
|
55
|
+
onClick,
|
|
56
|
+
}: {
|
|
57
|
+
label: string
|
|
58
|
+
count: number
|
|
59
|
+
fillClass: string
|
|
60
|
+
/** Tone text class applied to the count when count > 0. */
|
|
61
|
+
textClass?: string
|
|
62
|
+
active?: boolean
|
|
63
|
+
onClick?: () => void
|
|
64
|
+
}) {
|
|
65
|
+
const inner = (
|
|
66
|
+
<>
|
|
67
|
+
<span className={clsx('h-2 w-2 rounded-full', fillClass, count === 0 && 'opacity-30')} />
|
|
68
|
+
<span className={clsx('font-semibold tabular-nums', count > 0 ? textClass : 'text-theme-text-tertiary')}>{count}</span>
|
|
69
|
+
<span>{label}</span>
|
|
70
|
+
</>
|
|
71
|
+
)
|
|
72
|
+
if (!onClick) {
|
|
73
|
+
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-theme-text-secondary">{inner}</span>
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={onClick}
|
|
79
|
+
aria-pressed={active}
|
|
80
|
+
className={clsx(
|
|
81
|
+
'group inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
|
82
|
+
active ? 'border-theme-border bg-theme-elevated text-theme-text-primary' : 'border-transparent text-theme-text-secondary hover:bg-theme-hover/60',
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{inner}
|
|
86
|
+
</button>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Info } from 'lucide-react'
|
|
4
|
+
import { Tooltip } from './Tooltip'
|
|
5
|
+
|
|
6
|
+
// Shared faceted-filter primitives for sidebar filter rails. One implementation
|
|
7
|
+
// behind the GitOps and Applications rails so the "this filter is on" language
|
|
8
|
+
// stays identical everywhere — active = the skyhook accent, the same signal the
|
|
9
|
+
// nav rail and header status tiles use.
|
|
10
|
+
|
|
11
|
+
export type FacetTone = 'neutral' | 'success' | 'warning' | 'error' | 'info'
|
|
12
|
+
|
|
13
|
+
const DOT_CLASS: Record<FacetTone, string> = {
|
|
14
|
+
neutral: 'bg-theme-text-tertiary',
|
|
15
|
+
success: 'bg-emerald-500',
|
|
16
|
+
warning: 'bg-amber-500',
|
|
17
|
+
error: 'bg-red-500',
|
|
18
|
+
info: 'bg-sky-500',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A titled group in a filter rail: optional leading icon, optional info tooltip,
|
|
22
|
+
// and a bottom divider so stacked sections read as distinct facets.
|
|
23
|
+
export function FacetSection({
|
|
24
|
+
icon: Icon,
|
|
25
|
+
title,
|
|
26
|
+
info,
|
|
27
|
+
children,
|
|
28
|
+
}: {
|
|
29
|
+
icon?: ComponentType<{ className?: string }>
|
|
30
|
+
title: string
|
|
31
|
+
info?: ReactNode
|
|
32
|
+
children: ReactNode
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<section className="border-b border-theme-border px-3 py-2">
|
|
36
|
+
<div className="mb-1.5 flex items-center gap-1.5">
|
|
37
|
+
{Icon && <Icon className="h-3.5 w-3.5 text-theme-text-tertiary" />}
|
|
38
|
+
<span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{title}</span>
|
|
39
|
+
{info && (
|
|
40
|
+
<Tooltip content={info} delay={150} position="right">
|
|
41
|
+
<Info className="h-3 w-3 cursor-default text-theme-text-tertiary/70 hover:text-theme-text-secondary" aria-label={`About ${title}`} />
|
|
42
|
+
</Tooltip>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
<div className="space-y-0.5">{children}</div>
|
|
46
|
+
</section>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A single multi-select filter toggle: leading status dot (tone-colored), label,
|
|
51
|
+
// trailing count. Active uses the skyhook accent.
|
|
52
|
+
export function FacetButton({
|
|
53
|
+
label,
|
|
54
|
+
count,
|
|
55
|
+
active,
|
|
56
|
+
tone = 'neutral',
|
|
57
|
+
tooltip,
|
|
58
|
+
onClick,
|
|
59
|
+
}: {
|
|
60
|
+
label: string
|
|
61
|
+
count?: number
|
|
62
|
+
active: boolean
|
|
63
|
+
tone?: FacetTone
|
|
64
|
+
tooltip?: ReactNode
|
|
65
|
+
onClick: () => void
|
|
66
|
+
}) {
|
|
67
|
+
const btn = (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={onClick}
|
|
71
|
+
aria-pressed={active}
|
|
72
|
+
className={clsx(
|
|
73
|
+
'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] transition-colors',
|
|
74
|
+
active
|
|
75
|
+
? 'bg-skyhook-500/15 text-skyhook-700 dark:text-skyhook-300'
|
|
76
|
+
: 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
<span className={clsx('h-2 w-2 shrink-0 rounded-full', DOT_CLASS[tone])} />
|
|
80
|
+
<span className="min-w-0 flex-1 truncate font-medium">{label}</span>
|
|
81
|
+
{count != null && count > 0 && <span className="tabular-nums text-theme-text-tertiary">{count}</span>}
|
|
82
|
+
</button>
|
|
83
|
+
)
|
|
84
|
+
return tooltip ? (
|
|
85
|
+
<Tooltip content={tooltip} delay={300} position="right" wrapperClassName="w-full">
|
|
86
|
+
{btn}
|
|
87
|
+
</Tooltip>
|
|
88
|
+
) : (
|
|
89
|
+
btn
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Convenience wrapper: a FacetSection of multi-select FacetButtons driven by a
|
|
94
|
+
// Set. Auto-hides zero-count options (quieter than a hard slice cap) unless
|
|
95
|
+
// hideZero is turned off.
|
|
96
|
+
export function Facet<T extends string>({
|
|
97
|
+
title,
|
|
98
|
+
icon,
|
|
99
|
+
info,
|
|
100
|
+
options,
|
|
101
|
+
selected,
|
|
102
|
+
onToggle,
|
|
103
|
+
hideZero = true,
|
|
104
|
+
}: {
|
|
105
|
+
title: string
|
|
106
|
+
icon?: ComponentType<{ className?: string }>
|
|
107
|
+
info?: ReactNode
|
|
108
|
+
options: { value: T; label: string; count: number; tone?: FacetTone; tooltip?: ReactNode }[]
|
|
109
|
+
selected: Set<T>
|
|
110
|
+
onToggle: (v: T) => void
|
|
111
|
+
hideZero?: boolean
|
|
112
|
+
}) {
|
|
113
|
+
const visible = hideZero ? options.filter((o) => o.count > 0) : options
|
|
114
|
+
if (visible.length === 0) return null
|
|
115
|
+
return (
|
|
116
|
+
<FacetSection icon={icon} title={title} info={info}>
|
|
117
|
+
{visible.map((o) => (
|
|
118
|
+
<FacetButton
|
|
119
|
+
key={o.value}
|
|
120
|
+
label={o.label}
|
|
121
|
+
count={o.count}
|
|
122
|
+
active={selected.has(o.value)}
|
|
123
|
+
tone={o.tone}
|
|
124
|
+
tooltip={o.tooltip}
|
|
125
|
+
onClick={() => onToggle(o.value)}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</FacetSection>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
2
|
+
import { ArrowLeft } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
// Standard view page header — icon + title + description, optional back button
|
|
5
|
+
// and right-aligned custom content (counts, tiles, settings, actions…). The
|
|
6
|
+
// consistent "where am I / what is this page" signal across views (Rancher
|
|
7
|
+
// Masthead / Headlamp SectionHeader pattern).
|
|
8
|
+
interface PageHeaderProps {
|
|
9
|
+
icon: ComponentType<{ className?: string }>
|
|
10
|
+
title: string
|
|
11
|
+
description?: string
|
|
12
|
+
/** Renders a back button to the left of the title when provided. */
|
|
13
|
+
onBack?: () => void
|
|
14
|
+
/** Right-aligned custom content — wraps onto multiple lines when it overflows
|
|
15
|
+
* (e.g. a row of count tiles). */
|
|
16
|
+
actions?: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex items-center gap-4">
|
|
22
|
+
{onBack && (
|
|
23
|
+
<button
|
|
24
|
+
onClick={onBack}
|
|
25
|
+
aria-label="Back"
|
|
26
|
+
className="p-1.5 rounded-lg hover:bg-theme-hover transition-colors shrink-0"
|
|
27
|
+
>
|
|
28
|
+
<ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
|
|
29
|
+
</button>
|
|
30
|
+
)}
|
|
31
|
+
<div className="flex-1 min-w-0">
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
<Icon className="w-5 h-5 text-theme-text-secondary shrink-0" />
|
|
34
|
+
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{title}</h1>
|
|
35
|
+
</div>
|
|
36
|
+
{description && (
|
|
37
|
+
<p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate">{description}</p>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
{actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0">{actions}</div>}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|