@skyhook-io/k8s-ui 1.7.14 → 1.8.0
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 +7 -7
- 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 +12 -23
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +298 -274
- package/src/components/issues/IssuesView.tsx +4 -6
- package/src/components/issues/severity.ts +19 -11
- package/src/components/issues/types.ts +22 -2
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +180 -21
- package/src/components/resources/get-pod-problems.test.ts +127 -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/PodRenderer.test.tsx +56 -0
- package/src/components/resources/renderers/PodRenderer.tsx +1 -1
- 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 +41 -28
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- 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 +237 -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 +64 -2
- package/src/utils/bulk-workload-actions.test.ts +78 -0
- package/src/utils/bulk-workload-actions.ts +50 -0
- package/src/utils/index.ts +1 -0
- 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,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
|
|
@@ -267,26 +268,39 @@ export function getPodProblems(pod: any): PodProblem[] {
|
|
|
267
268
|
const initContainerStatuses = pod.status?.initContainerStatuses || []
|
|
268
269
|
const conditions = pod.status?.conditions || []
|
|
269
270
|
const phase = pod.status?.phase
|
|
271
|
+
const podStatusMessage = pod.status?.message || undefined
|
|
272
|
+
const hasPodIP = Boolean(pod.status?.podIP || pod.status?.podIPs?.some((ip: any) => ip?.ip))
|
|
273
|
+
const hasScheduledNode = Boolean(pod.spec?.nodeName)
|
|
274
|
+
const hasUnschedulableCondition = conditions.some((cond: any) => cond.type === 'PodScheduled' && cond.status === 'False')
|
|
275
|
+
const hasContainerCreating = containerStatuses.some((cs: any) => cs.state?.waiting?.reason === 'ContainerCreating')
|
|
276
|
+
const createdAtMs = pod.metadata?.creationTimestamp ? new Date(pod.metadata.creationTimestamp).getTime() : NaN
|
|
277
|
+
const podAgeMs = Number.isFinite(createdAtMs) ? Date.now() - createdAtMs : 0
|
|
278
|
+
const sandboxStartupStallAgeMs = 10 * 60 * 1000
|
|
279
|
+
const sandboxStartupStallCriticalAgeMs = 30 * 60 * 1000
|
|
280
|
+
const inferredSandboxStartupStall = phase === 'Pending' && hasScheduledNode && !hasUnschedulableCondition && hasContainerCreating && !hasPodIP && podAgeMs > sandboxStartupStallAgeMs
|
|
281
|
+
const inferredSandboxStartupStallSeverity: PodProblem['severity'] = podAgeMs >= sandboxStartupStallCriticalAgeMs ? 'critical' : 'high'
|
|
282
|
+
let hasSandboxStartupStallProblem = false
|
|
270
283
|
|
|
271
284
|
// Failed or Unknown phase
|
|
272
285
|
if (phase === 'Failed' && pod.status?.reason !== 'Evicted') {
|
|
273
|
-
problems.push({ severity: 'critical', message: 'Failed' })
|
|
286
|
+
problems.push({ severity: 'critical', message: 'Failed', detail: podStatusMessage })
|
|
274
287
|
} else if (phase === 'Unknown') {
|
|
275
|
-
problems.push({ severity: 'high', message: 'Unknown' })
|
|
288
|
+
problems.push({ severity: 'high', message: 'Unknown', detail: podStatusMessage })
|
|
276
289
|
}
|
|
277
290
|
|
|
278
291
|
// Init container failures
|
|
279
292
|
for (const cs of initContainerStatuses) {
|
|
280
293
|
if (cs.state?.waiting?.reason && cs.state.waiting.reason !== 'PodInitializing') {
|
|
281
294
|
const reason = cs.state.waiting.reason
|
|
295
|
+
const detail = cs.state.waiting.message || undefined
|
|
282
296
|
if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
|
|
283
|
-
problems.push({ severity: 'critical', message: `Init: ${reason}
|
|
297
|
+
problems.push({ severity: 'critical', message: `Init: ${reason}`, detail })
|
|
284
298
|
} else {
|
|
285
|
-
problems.push({ severity: 'high', message: `Init: ${reason}
|
|
299
|
+
problems.push({ severity: 'high', message: `Init: ${reason}`, detail })
|
|
286
300
|
}
|
|
287
301
|
}
|
|
288
302
|
if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
|
|
289
|
-
problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}
|
|
303
|
+
problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}`, detail: cs.state.terminated.message || undefined })
|
|
290
304
|
}
|
|
291
305
|
}
|
|
292
306
|
|
|
@@ -294,30 +308,32 @@ export function getPodProblems(pod: any): PodProblem[] {
|
|
|
294
308
|
// Check waiting state
|
|
295
309
|
if (cs.state?.waiting?.reason) {
|
|
296
310
|
const reason = cs.state.waiting.reason
|
|
311
|
+
const detail = cs.state.waiting.message || undefined
|
|
297
312
|
if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
|
|
298
|
-
problems.push({ severity: 'critical', message: reason })
|
|
313
|
+
problems.push({ severity: 'critical', message: reason, detail })
|
|
299
314
|
} else if (reason === 'CreateContainerConfigError') {
|
|
300
|
-
problems.push({ severity: 'critical', message: 'Config Error' })
|
|
315
|
+
problems.push({ severity: 'critical', message: 'Config Error', detail })
|
|
301
316
|
} else if (reason === 'ContainerCannotRun') {
|
|
302
|
-
problems.push({ severity: 'critical', message: 'Cannot Run' })
|
|
317
|
+
problems.push({ severity: 'critical', message: 'Cannot Run', detail })
|
|
303
318
|
} else if (reason !== 'ContainerCreating' && reason !== 'PodInitializing') {
|
|
304
|
-
problems.push({ severity: 'high', message: reason })
|
|
319
|
+
problems.push({ severity: 'high', message: reason, detail })
|
|
305
320
|
}
|
|
306
321
|
}
|
|
307
322
|
// Check terminated state
|
|
308
323
|
if (cs.state?.terminated?.reason === 'OOMKilled') {
|
|
309
|
-
problems.push({ severity: 'critical', message: 'OOMKilled' })
|
|
324
|
+
problems.push({ severity: 'critical', message: 'OOMKilled', detail: cs.state.terminated.message || undefined })
|
|
310
325
|
} else if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
|
|
311
|
-
problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}
|
|
326
|
+
problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}`, detail: cs.state.terminated.message || undefined })
|
|
312
327
|
}
|
|
313
328
|
// High restart count
|
|
314
329
|
if (cs.restartCount > 5) {
|
|
315
330
|
problems.push({ severity: 'medium', message: `${cs.restartCount} restarts` })
|
|
316
331
|
}
|
|
317
332
|
// Volume mount issues from last state
|
|
318
|
-
const
|
|
333
|
+
const lastMsgRaw = cs.lastState?.terminated?.message || ''
|
|
334
|
+
const lastMsg = lastMsgRaw.toLowerCase()
|
|
319
335
|
if (lastMsg.includes('failed to mount') || lastMsg.includes('failedattachvolume')) {
|
|
320
|
-
problems.push({ severity: 'high', message: 'Volume Mount Failed' })
|
|
336
|
+
problems.push({ severity: 'high', message: 'Volume Mount Failed', detail: lastMsgRaw || undefined })
|
|
321
337
|
}
|
|
322
338
|
}
|
|
323
339
|
|
|
@@ -332,23 +348,29 @@ export function getPodProblems(pod: any): PodProblem[] {
|
|
|
332
348
|
if (cond.type === 'ContainersReady' && cond.status === 'False') {
|
|
333
349
|
const msg = (cond.message || '').toLowerCase()
|
|
334
350
|
if (msg.includes('readiness')) {
|
|
335
|
-
problems.push({ severity: 'medium', message: 'Readiness Probe Failing' })
|
|
351
|
+
problems.push({ severity: 'medium', message: 'Readiness Probe Failing', detail: cond.message || undefined })
|
|
336
352
|
} else if (msg.includes('liveness')) {
|
|
337
|
-
problems.push({ severity: 'high', message: 'Liveness Probe Failing' })
|
|
353
|
+
problems.push({ severity: 'high', message: 'Liveness Probe Failing', detail: cond.message || undefined })
|
|
338
354
|
}
|
|
339
355
|
}
|
|
340
356
|
// IP allocation failures (subnet exhaustion)
|
|
341
357
|
if (cond.type === 'PodReadyToStartContainers' && cond.status === 'False') {
|
|
342
358
|
const msg = (cond.message || '').toLowerCase()
|
|
343
|
-
if (msg.includes('failed to assign an ip')
|
|
344
|
-
problems.push({ severity: 'critical', message: 'IP Allocation Failed' })
|
|
359
|
+
if (msg.includes('failed to assign an ip')) {
|
|
360
|
+
problems.push({ severity: 'critical', message: 'IP Allocation Failed', detail: cond.message || undefined })
|
|
361
|
+
} else if (msg.includes('pod sandbox')) {
|
|
362
|
+
problems.push({ severity: 'critical', message: 'Sandbox Startup Stalled', detail: cond.message || undefined })
|
|
363
|
+
hasSandboxStartupStallProblem = true
|
|
345
364
|
}
|
|
346
365
|
}
|
|
347
366
|
}
|
|
367
|
+
if (inferredSandboxStartupStall && !hasSandboxStartupStallProblem) {
|
|
368
|
+
problems.push({ severity: inferredSandboxStartupStallSeverity, message: 'Sandbox Startup Stalled' })
|
|
369
|
+
}
|
|
348
370
|
|
|
349
371
|
// Evicted pods
|
|
350
372
|
if (phase === 'Failed' && pod.status?.reason === 'Evicted') {
|
|
351
|
-
problems.push({ severity: 'high', message: 'Evicted' })
|
|
373
|
+
problems.push({ severity: 'high', message: 'Evicted', detail: podStatusMessage })
|
|
352
374
|
}
|
|
353
375
|
|
|
354
376
|
// Stuck terminating (zombie pod)
|
|
@@ -939,16 +961,7 @@ export function getCronJobLastRun(cj: any): string | null {
|
|
|
939
961
|
// ============================================================================
|
|
940
962
|
|
|
941
963
|
export function getHPAStatus(hpa: any): StatusBadge {
|
|
942
|
-
|
|
943
|
-
const desired = hpa.status?.desiredReplicas || 0
|
|
944
|
-
|
|
945
|
-
if (current === desired) {
|
|
946
|
-
return { text: 'Stable', color: healthColors.healthy, level: 'healthy' }
|
|
947
|
-
}
|
|
948
|
-
if (current < desired) {
|
|
949
|
-
return { text: 'Scaling Up', color: healthColors.degraded, level: 'degraded' }
|
|
950
|
-
}
|
|
951
|
-
return { text: 'Scaling Down', color: healthColors.degraded, level: 'degraded' }
|
|
964
|
+
return hpaStatusFromState(getHPATableState(hpa), healthColors)
|
|
952
965
|
}
|
|
953
966
|
|
|
954
967
|
export function getHPAReplicas(hpa: any): { current: number; min: number; max: number } {
|
|
@@ -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} />}
|
|
@@ -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
|
+
}
|