@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.
Files changed (48) hide show
  1. package/package.json +7 -7
  2. package/src/components/applications/ApplicationsList.tsx +131 -115
  3. package/src/components/applications/index.ts +3 -1
  4. package/src/components/audit/AuditCard.tsx +1 -1
  5. package/src/components/charts/AreaChart.tsx +52 -18
  6. package/src/components/charts/MetricsSummary.tsx +12 -3
  7. package/src/components/charts/saturation.test.ts +34 -0
  8. package/src/components/charts/saturation.ts +1 -0
  9. package/src/components/charts/types.ts +5 -4
  10. package/src/components/checks/ChecksView.tsx +12 -23
  11. package/src/components/checks/severity.ts +11 -11
  12. package/src/components/gitops/GitOpsTableView.tsx +298 -274
  13. package/src/components/issues/IssuesView.tsx +4 -6
  14. package/src/components/issues/severity.ts +19 -11
  15. package/src/components/issues/types.ts +22 -2
  16. package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
  17. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  18. package/src/components/resources/ResourcesView.tsx +180 -21
  19. package/src/components/resources/get-pod-problems.test.ts +127 -0
  20. package/src/components/resources/index.ts +1 -0
  21. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  22. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  23. package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
  24. package/src/components/resources/renderers/PodRenderer.tsx +1 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  27. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  28. package/src/components/resources/resource-utils-hpa.ts +106 -0
  29. package/src/components/resources/resource-utils.ts +41 -28
  30. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  31. package/src/components/ui/DistributionBar.tsx +88 -0
  32. package/src/components/ui/Facet.tsx +130 -0
  33. package/src/components/ui/PageHeader.tsx +43 -0
  34. package/src/components/ui/SearchPillInput.tsx +237 -0
  35. package/src/components/ui/SortableTh.tsx +58 -0
  36. package/src/components/ui/SummaryTile.tsx +60 -0
  37. package/src/components/ui/drawer-components.tsx +56 -17
  38. package/src/components/ui/index.ts +12 -0
  39. package/src/components/workload/WorkloadView.tsx +18 -1
  40. package/src/theme/tailwind-theme.css +1 -0
  41. package/src/theme/variables.css +7 -3
  42. package/src/types/core.ts +64 -2
  43. package/src/utils/bulk-workload-actions.test.ts +78 -0
  44. package/src/utils/bulk-workload-actions.ts +50 -0
  45. package/src/utils/index.ts +1 -0
  46. package/src/utils/rbac-blast-radius.test.ts +48 -3
  47. package/src/utils/rbac-blast-radius.ts +14 -4
  48. package/tsconfig.json +1 -0
package/src/types/core.ts CHANGED
@@ -47,6 +47,15 @@ export const OPTIONAL_RESOURCE_KINDS: ReadonlyArray<keyof ResourcePermissions> =
47
47
  'verticalPodAutoscalers',
48
48
  ]
49
49
 
50
+ // Per-workload write permissions. Field names must match
51
+ // WorkloadWritePermissions in internal/k8s/capabilities.go.
52
+ export interface WorkloadWritePermissions {
53
+ deployments: boolean
54
+ daemonSets: boolean
55
+ statefulSets: boolean
56
+ rollouts: boolean
57
+ }
58
+
50
59
  // Feature capabilities based on RBAC permissions
51
60
  export interface Capabilities {
52
61
  exec: boolean // Terminal feature (pods/exec)
@@ -57,6 +66,7 @@ export interface Capabilities {
57
66
  secretsUpdate: boolean // Update secrets (inline editing)
58
67
  helmWrite: boolean // Helm write operations (install, upgrade, rollback, uninstall, apply values)
59
68
  nodeWrite: boolean // Node write operations (cordon, uncordon, drain)
69
+ workloadWrites?: WorkloadWritePermissions // Workload patch permissions (restart/scale controls)
60
70
  mcpEnabled: boolean // MCP server is running
61
71
  // How / where this Radar binary is running. Optional on the wire so a
62
72
  // newer frontend (e.g. radar-hub-web bundling a fresher @skyhook-io/radar-app)
@@ -462,11 +472,63 @@ export interface SecretCertificateInfo {
462
472
  certificates: CertificateInfo[]
463
473
  }
464
474
 
475
+ export type HPADiagnosisState =
476
+ | 'ok'
477
+ | 'scaling_up'
478
+ | 'scaling_down'
479
+ | 'limited_max'
480
+ | 'limited_min'
481
+ | 'metrics_unavailable'
482
+ | 'metrics_incomplete'
483
+ | 'unable_to_scale'
484
+ | 'disabled'
485
+ | 'pinned'
486
+ | 'stale'
487
+ | 'stabilized'
488
+ | 'unknown'
489
+
490
+ export interface HPADiagnosis {
491
+ state: HPADiagnosisState
492
+ summary: string
493
+ target: {
494
+ apiVersion?: string
495
+ kind?: string
496
+ name?: string
497
+ }
498
+ bounds: {
499
+ min: number
500
+ max: number
501
+ current: number
502
+ desired: number
503
+ observedGeneration?: number
504
+ generation?: number
505
+ }
506
+ metrics?: HPAMetricSummary[]
507
+ reasons?: HPAReasonSummary[]
508
+ }
509
+
510
+ export interface HPAReasonSummary {
511
+ id: string
512
+ message: string
513
+ detail?: string
514
+ conditionType?: string
515
+ conditionReason?: string
516
+ }
517
+
518
+ export interface HPAMetricSummary {
519
+ type: string
520
+ name: string
521
+ current?: string
522
+ target?: string
523
+ status: string
524
+ }
525
+
465
526
  // Resource with computed relationships and optional certificate info (API response wrapper)
466
527
  export interface ResourceWithRelationships<T = unknown> {
467
528
  resource: T
468
529
  relationships?: Relationships
469
530
  certificateInfo?: SecretCertificateInfo
531
+ hpaDiagnosis?: HPADiagnosis
470
532
  }
471
533
 
472
534
  // API Resource (from discovery endpoint)
@@ -937,11 +999,11 @@ export interface TrafficFilters {
937
999
  timeRange: string
938
1000
  }
939
1001
 
940
- // Main view type now includes 'traffic', 'cost', 'audit', 'gitops'.
1002
+ // Main view type now includes 'traffic', 'cost', 'checks', 'gitops'.
941
1003
  // Library consumers (Radar Hub) get all GitOps surfaces — the package
942
1004
  // IS the public surface, so adding new top-level views must extend
943
1005
  // this type rather than rely on app-local extensions.
944
- export type ExtendedMainView = MainView | 'traffic' | 'cost' | 'audit' | 'gitops' | 'issues'
1006
+ export type ExtendedMainView = MainView | 'traffic' | 'cost' | 'checks' | 'gitops' | 'issues' | 'applications'
945
1007
 
946
1008
  // ============================================================================
947
1009
  // Image Filesystem Types
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ canBulkRestartKind,
4
+ canBulkScaleKind,
5
+ intersectWorkloadWrites,
6
+ } from './bulk-workload-actions'
7
+ import type { Capabilities, WorkloadWritePermissions } from '../types/core'
8
+
9
+ const writes = (overrides: Partial<WorkloadWritePermissions> = {}): WorkloadWritePermissions => ({
10
+ deployments: false,
11
+ daemonSets: false,
12
+ statefulSets: false,
13
+ rollouts: false,
14
+ ...overrides,
15
+ })
16
+
17
+ const caps = (workloadWrites: WorkloadWritePermissions): Pick<Capabilities, 'workloadWrites'> => ({
18
+ workloadWrites,
19
+ })
20
+
21
+ describe('bulk workload action gating', () => {
22
+ it('allows restart for patchable workload kinds', () => {
23
+ const all = writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })
24
+
25
+ expect(canBulkRestartKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
26
+ expect(canBulkRestartKind({ name: 'daemonsets', group: 'apps' }, all)).toBe(true)
27
+ expect(canBulkRestartKind({ name: 'statefulsets', group: 'apps' }, all)).toBe(true)
28
+ expect(canBulkRestartKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(true)
29
+ })
30
+
31
+ it('requires the apps group for built-in workload restart', () => {
32
+ const all = writes({ deployments: true, daemonSets: true, statefulSets: true })
33
+
34
+ expect(canBulkRestartKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
35
+ expect(canBulkRestartKind({ name: 'deployments', group: 'example.com' }, all)).toBe(false)
36
+ expect(canBulkRestartKind({ name: 'deployments' }, all)).toBe(false)
37
+ expect(canBulkRestartKind({ name: 'daemonsets', group: 'example.com' }, all)).toBe(false)
38
+ expect(canBulkRestartKind({ name: 'statefulsets', group: 'example.com' }, all)).toBe(false)
39
+ })
40
+
41
+ it('requires the Argo group for rollout restart', () => {
42
+ const all = writes({ rollouts: true })
43
+
44
+ expect(canBulkRestartKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(true)
45
+ expect(canBulkRestartKind({ name: 'rollouts', group: 'example.com' }, all)).toBe(false)
46
+ expect(canBulkRestartKind({ name: 'rollouts' }, all)).toBe(false)
47
+ })
48
+
49
+ it('allows scale only for deployments and statefulsets', () => {
50
+ const all = writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })
51
+
52
+ expect(canBulkScaleKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
53
+ expect(canBulkScaleKind({ name: 'statefulsets', group: 'apps' }, all)).toBe(true)
54
+ expect(canBulkScaleKind({ name: 'daemonsets', group: 'apps' }, all)).toBe(false)
55
+ expect(canBulkScaleKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(false)
56
+ expect(canBulkScaleKind({ name: 'deployments', group: 'example.com' }, all)).toBe(false)
57
+ expect(canBulkScaleKind({ name: 'deployments' }, all)).toBe(false)
58
+ })
59
+
60
+ it('withholds actions when permissions are absent', () => {
61
+ expect(canBulkRestartKind({ name: 'deployments', group: 'apps' }, undefined)).toBe(false)
62
+ expect(canBulkScaleKind({ name: 'deployments', group: 'apps' }, undefined)).toBe(false)
63
+ expect(intersectWorkloadWrites(undefined)).toBeUndefined()
64
+ expect(intersectWorkloadWrites([])).toBeUndefined()
65
+ })
66
+
67
+ it('intersects multi-namespace permissions with AND semantics', () => {
68
+ expect(intersectWorkloadWrites([
69
+ caps(writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })),
70
+ caps(writes({ deployments: true, statefulSets: true })),
71
+ ])).toEqual({
72
+ deployments: true,
73
+ daemonSets: false,
74
+ statefulSets: true,
75
+ rollouts: false,
76
+ })
77
+ })
78
+ })
@@ -0,0 +1,50 @@
1
+ import type { Capabilities, WorkloadWritePermissions } from '../types/core'
2
+
3
+ export type BulkWorkloadKindInfo = {
4
+ name: string
5
+ group?: string
6
+ }
7
+
8
+ export function canBulkRestartKind(
9
+ kind: BulkWorkloadKindInfo | null | undefined,
10
+ writes: WorkloadWritePermissions | undefined,
11
+ ): boolean {
12
+ switch (kind?.name.toLowerCase()) {
13
+ case 'deployments':
14
+ return kind.group === 'apps' && writes?.deployments === true
15
+ case 'daemonsets':
16
+ return kind.group === 'apps' && writes?.daemonSets === true
17
+ case 'statefulsets':
18
+ return kind.group === 'apps' && writes?.statefulSets === true
19
+ case 'rollouts':
20
+ return kind.group === 'argoproj.io' && writes?.rollouts === true
21
+ default:
22
+ return false
23
+ }
24
+ }
25
+
26
+ export function canBulkScaleKind(
27
+ kind: BulkWorkloadKindInfo | null | undefined,
28
+ writes: WorkloadWritePermissions | undefined,
29
+ ): boolean {
30
+ switch (kind?.name.toLowerCase()) {
31
+ case 'deployments':
32
+ return kind.group === 'apps' && writes?.deployments === true
33
+ case 'statefulsets':
34
+ return kind.group === 'apps' && writes?.statefulSets === true
35
+ default:
36
+ return false
37
+ }
38
+ }
39
+
40
+ export function intersectWorkloadWrites(
41
+ capabilities: Array<Pick<Capabilities, 'workloadWrites'>> | undefined,
42
+ ): WorkloadWritePermissions | undefined {
43
+ if (!capabilities || capabilities.length === 0) return undefined
44
+ return {
45
+ deployments: capabilities.every(c => c.workloadWrites?.deployments === true),
46
+ daemonSets: capabilities.every(c => c.workloadWrites?.daemonSets === true),
47
+ statefulSets: capabilities.every(c => c.workloadWrites?.statefulSets === true),
48
+ rollouts: capabilities.every(c => c.workloadWrites?.rollouts === true),
49
+ }
50
+ }
@@ -22,3 +22,4 @@ export * from './rbac-badges'
22
22
  export * from './git-provider-urls'
23
23
  export * from './applications'
24
24
  export * from './topology-neighborhood'
25
+ export * from './bulk-workload-actions'
@@ -35,15 +35,59 @@ describe('detectBlastRadius', () => {
35
35
  expect(reasons[0].reason).toMatch(/cluster-admin/)
36
36
  })
37
37
 
38
- it('flags verb wildcards', () => {
38
+ it('flags an admin-shaped grant via ClusterRoleBinding', () => {
39
+ // apiGroups/resources/verbs all `*` bound cluster-wide is god-mode.
39
40
  const b = binding({
40
- rules: [{ verbs: ['*'], resources: ['pods'], apiGroups: [''] }],
41
+ binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'admin-crb', roleRef: { kind: 'ClusterRole', namespace: '', name: 'admin' } },
42
+ rules: [{ verbs: ['*'], resources: ['*'], apiGroups: ['*'] }],
41
43
  })
42
44
  const reasons = detectBlastRadius(subject([b]))
43
45
  expect(reasons).toHaveLength(1)
44
46
  expect(reasons[0].reason).toMatch(/verb wildcard/)
45
47
  })
46
48
 
49
+ it('does NOT flag the same admin-shaped grant via a namespace RoleBinding', () => {
50
+ // A RoleBinding is namespace-scoped by apiserver construction — even
51
+ // `*/*/*` is namespace-admin, not cluster takeover, so it stays collapsed.
52
+ const b = binding({
53
+ rules: [{ verbs: ['*'], resources: ['*'], apiGroups: ['*'] }],
54
+ })
55
+ expect(detectBlastRadius(subject([b]))).toHaveLength(0)
56
+ })
57
+
58
+ it('flags verb wildcards on a cluster-wide binding (incl. cluster-wide secrets)', () => {
59
+ // Cluster-wide `verbs:['*']` on secrets is read-every-secret takeover and
60
+ // MUST stay loud even though the resource is named, not `*`.
61
+ const b = binding({
62
+ binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'cluster-b', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
63
+ rules: [{ verbs: ['*'], resources: ['secrets'], apiGroups: [''] }],
64
+ })
65
+ const reasons = detectBlastRadius(subject([b]))
66
+ expect(reasons).toHaveLength(1)
67
+ expect(reasons[0].reason).toMatch(/verb wildcard/)
68
+ })
69
+
70
+ it("does NOT flag a verb wildcard scoped to one operator's own CRDs", () => {
71
+ // `verbs:['*']` confined to a specific apiGroup+resources in a single
72
+ // namespace (RoleBinding) is the routine operator-owns-its-CRDs grant —
73
+ // expanding the Permissions section on every such SA is the over-fire
74
+ // this exclusion fixes.
75
+ const b = binding({
76
+ rules: [{ verbs: ['*'], resources: ['widgets'], apiGroups: ['acme.example.com'] }],
77
+ })
78
+ expect(detectBlastRadius(subject([b]))).toHaveLength(0)
79
+ })
80
+
81
+ it('does NOT flag a namespace RoleBinding with resource-wildcard verb-wildcard over one group', () => {
82
+ // `verbs:['*']` over `resources:['*']` in one apiGroup, bound via a
83
+ // namespace RoleBinding, is namespace-scoped — broad within the namespace
84
+ // but not cluster takeover. Common operator shape; stays collapsed.
85
+ const b = binding({
86
+ rules: [{ verbs: ['*'], resources: ['*'], apiGroups: ['acme.example.com'] }],
87
+ })
88
+ expect(detectBlastRadius(subject([b]))).toHaveLength(0)
89
+ })
90
+
47
91
  it('flags escalation verbs', () => {
48
92
  const b = binding({
49
93
  rules: [{ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] }],
@@ -89,8 +133,9 @@ describe('detectBlastRadius', () => {
89
133
 
90
134
  it('dedupes by binding identity when multiple risky rules match', () => {
91
135
  const b = binding({
136
+ binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'cluster-b', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
92
137
  rules: [
93
- { verbs: ['*'], resources: ['pods'], apiGroups: [''] },
138
+ { verbs: ['*'], resources: ['secrets'], apiGroups: [''] },
94
139
  { verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] },
95
140
  ],
96
141
  })
@@ -47,14 +47,19 @@ export interface BlastRadiusReason {
47
47
  *
48
48
  * 1. cluster-admin role-name match (covers the "bound my SA to cluster-admin
49
49
  * for testing" pattern even when rules aren't loaded)
50
- * 2. verb wildcards (`*`)
50
+ * 2. verb wildcards (`*`) granted by a cluster-wide binding (ClusterRoleBinding)
51
51
  * 3. escalation verbs (`escalate` / `bind` / `impersonate`)
52
52
  * 4. cluster-wide `create pods` (lateral-movement vector)
53
53
  *
54
54
  * Resource-only wildcards (e.g. `view`'s fluxcd CRD coverage) deliberately
55
55
  * do NOT trigger — they fire on every authenticated SA and would train
56
- * operators to ignore the alarm. The current set is the calibrated minimum
57
- * for "the Pod's identity is genuinely god-mode".
56
+ * operators to ignore the alarm. A verb wildcard granted by a RoleBinding is
57
+ * likewise excluded — a RoleBinding is namespace-scoped by apiserver
58
+ * construction, so even `verbs:['*']` over `resources:['*']` across every
59
+ * apiGroup (namespace-admin) is not cluster takeover, and operators routinely
60
+ * hold broad wildcards over their own CRDs. Only a cluster-wide binding counts
61
+ * as genuine god-mode. The current set is the calibrated minimum for "the
62
+ * Pod's identity is genuinely god-mode".
58
63
  *
59
64
  * Dedupes by binding identity so a binding with multiple risky rules
60
65
  * counts once; first matching reason wins.
@@ -73,7 +78,12 @@ export function detectBlastRadius(rbacData: RBACSubjectResponse): BlastRadiusRea
73
78
  for (const r of br.rules ?? []) {
74
79
  const verbs = r.verbs ?? []
75
80
  const resources = r.resources ?? []
76
- if (verbs.includes('*')) {
81
+ // A verb wildcard is only cluster takeover when the binding is itself
82
+ // cluster-wide. A RoleBinding is namespace-scoped by apiserver
83
+ // construction no matter how broad its rule (apiGroups/resources `*`
84
+ // included), so namespace-admin SAs stay collapsed; only
85
+ // ClusterRoleBindings count.
86
+ if (verbs.includes('*') && br.binding.kind === 'ClusterRoleBinding') {
77
87
  reasons.push({ binding: br, reason: 'grants verb wildcard (*) — every action on the listed resources' })
78
88
  break
79
89
  }
package/tsconfig.json CHANGED
@@ -9,6 +9,7 @@
9
9
  "isolatedModules": true,
10
10
  "noEmit": true,
11
11
  "jsx": "react-jsx",
12
+ "types": ["node"],
12
13
  "strict": true,
13
14
  "noUnusedLocals": true,
14
15
  "noUnusedParameters": true,