@skyhook-io/k8s-ui 1.7.9 → 1.7.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.9",
3
+ "version": "1.7.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -201,6 +201,14 @@ export interface GitOpsTableViewProps {
201
201
  // detected" copy. Hub passes "No GitOps resources across the fleet".
202
202
  emptyStateTitle?: string
203
203
  emptyStateBody?: string
204
+ /**
205
+ * Which side the filter rail sits on. Default 'left' (OSS Radar, which
206
+ * has no app sidebar). A host with its own left navigation rail (the
207
+ * Cloud hub) passes 'right' so the GitOps filters don't stack a second
208
+ * column against that nav and instead match the host's other faceted
209
+ * pages. Mobile stacking (filters above the table) is unaffected.
210
+ */
211
+ filtersSide?: 'left' | 'right'
204
212
  /**
205
213
  * Global namespace pick from the host's NamespaceSwitcher. Used to
206
214
  * surface "viewing in namespace: X" context and to power the Clear
@@ -249,6 +257,7 @@ export function GitOpsTableView({
249
257
  onClearNamespaces,
250
258
  onRowAction,
251
259
  pendingRowActions,
260
+ filtersSide = 'left',
252
261
  }: GitOpsTableViewProps) {
253
262
  const searchInputRef = useRef<HTMLInputElement>(null)
254
263
  const [mode, setMode] = useState<GitOpsMode>('applications')
@@ -492,8 +501,13 @@ export function GitOpsTableView({
492
501
  ]
493
502
 
494
503
  return (
495
- <div className="flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col">
504
+ <div
505
+ className={`flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col ${
506
+ filtersSide === 'right' ? 'lg:flex-row-reverse' : ''
507
+ }`}
508
+ >
496
509
  <GitOpsFilterSidebar
510
+ side={filtersSide}
497
511
  mode={mode}
498
512
  onModeChange={setMode}
499
513
  modeCounts={modeCounts}
@@ -707,6 +721,7 @@ export function GitOpsTableView({
707
721
  // =============================================================================
708
722
 
709
723
  function GitOpsFilterSidebar({
724
+ side,
710
725
  mode,
711
726
  onModeChange,
712
727
  modeCounts,
@@ -729,6 +744,7 @@ function GitOpsFilterSidebar({
729
744
  onToggleNamespace,
730
745
  onClear,
731
746
  }: {
747
+ side: 'left' | 'right'
732
748
  mode: GitOpsMode
733
749
  onModeChange: (mode: GitOpsMode) => void
734
750
  modeCounts: Record<GitOpsMode, number>
@@ -752,7 +768,11 @@ function GitOpsFilterSidebar({
752
768
  onClear: () => void
753
769
  }) {
754
770
  return (
755
- <aside className="flex w-72 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-lg:max-h-72 max-lg:w-full max-lg:border-b max-lg:border-r-0">
771
+ <aside
772
+ className={`flex w-72 shrink-0 flex-col overflow-hidden border-theme-border bg-theme-surface/90 max-lg:max-h-72 max-lg:w-full max-lg:border-b ${
773
+ side === 'right' ? 'border-l max-lg:border-l-0' : 'border-r max-lg:border-r-0'
774
+ }`}
775
+ >
756
776
  <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
757
777
  <span className="text-sm font-medium text-theme-text-secondary">GitOps Filters</span>
758
778
  <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
@@ -215,6 +215,7 @@ function IssueRow({
215
215
  <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
216
216
  <div className="flex flex-col gap-4">
217
217
  <Diagnosis issue={issue} />
218
+ <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
218
219
  <div className="border-t border-theme-border/70 pt-3">
219
220
  <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
220
221
  </div>
@@ -249,6 +250,11 @@ function Diagnosis({ issue }: { issue: Issue }) {
249
250
  error) — so the precise message is never lost, just de-emphasized. */}
250
251
  {detail ? <p className="break-words font-mono text-xs leading-relaxed text-theme-text-tertiary">{detail}</p> : null}
251
252
  {crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
253
+ {issue.change_context ? (
254
+ <p className="text-xs text-theme-text-tertiary">
255
+ {changeContextText(issue.change_context)}
256
+ </p>
257
+ ) : null}
252
258
  {issue.first_seen ? (
253
259
  <p className="text-xs text-theme-text-tertiary tabular-nums">
254
260
  Started {formatRelativeAgeTime(issue.first_seen)}
@@ -259,6 +265,105 @@ function Diagnosis({ issue }: { issue: Issue }) {
259
265
  );
260
266
  }
261
267
 
268
+ function changeContextText(change: NonNullable<Issue['change_context']>): string {
269
+ const parts = [change.when ? `Changed ${change.when} ago` : 'Changed', change.what ? change.what.replace(/_/g, ' ') : null, change.evidence].filter(Boolean);
270
+ return parts.join(' · ');
271
+ }
272
+
273
+ function DiagnosticContext({
274
+ issue,
275
+ resourceHref,
276
+ onResourceClick,
277
+ }: {
278
+ issue: Issue;
279
+ resourceHref?: (ref: IssueResourceRef) => string;
280
+ onResourceClick?: (ref: IssueResourceRef) => void;
281
+ }) {
282
+ const ctx = issue.diagnostic_context;
283
+ const facts = ctx?.facts?.filter((fact) => fact.message || fact.refs?.length || fact.related_issues?.length) ?? [];
284
+ if (!ctx || facts.length === 0) return null;
285
+
286
+ return (
287
+ <section className="flex flex-col gap-2">
288
+ <div className="flex items-center gap-2">
289
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Context</h4>
290
+ {ctx.role ? <span className="badge-sm text-[10px] text-theme-text-secondary">{diagnosticRoleLabel(ctx.role)}</span> : null}
291
+ </div>
292
+ <ul className="flex flex-col gap-2">
293
+ {facts.map((fact, idx) => (
294
+ <li key={`${fact.type}-${idx}`} className="flex flex-col gap-1.5 rounded-md border border-theme-border/70 px-2.5 py-2">
295
+ <div className="flex min-w-0 items-baseline gap-2">
296
+ <span className="shrink-0 text-xs font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
297
+ {fact.message ? <span className="min-w-0 break-words text-xs leading-relaxed text-theme-text-tertiary">{fact.message}</span> : null}
298
+ </div>
299
+ {fact.related_issues?.length ? (
300
+ <ul className="flex flex-col gap-px">
301
+ {fact.related_issues.map((related, relIdx) => (
302
+ <ResourceLine
303
+ key={`${related.ref.group ?? ''}/${related.ref.kind}/${related.ref.namespace ?? ''}/${related.ref.name}#${relIdx}`}
304
+ label="Related"
305
+ refForLink={memberRef(issue, related.ref)}
306
+ resourceHref={resourceHref}
307
+ onResourceClick={onResourceClick}
308
+ />
309
+ ))}
310
+ </ul>
311
+ ) : null}
312
+ {fact.refs?.length ? (
313
+ <ul className="flex flex-col gap-px">
314
+ {fact.refs.map((ref, refIdx) => (
315
+ <ResourceLine
316
+ key={`${ref.group ?? ''}/${ref.kind}/${ref.namespace ?? ''}/${ref.name}#${refIdx}`}
317
+ refForLink={memberRef(issue, ref)}
318
+ resourceHref={resourceHref}
319
+ onResourceClick={onResourceClick}
320
+ />
321
+ ))}
322
+ </ul>
323
+ ) : null}
324
+ </li>
325
+ ))}
326
+ </ul>
327
+ </section>
328
+ );
329
+ }
330
+
331
+ function diagnosticRoleLabel(role: string): string {
332
+ switch (role) {
333
+ case 'candidate':
334
+ return 'Candidate signal';
335
+ case 'affected':
336
+ return 'Affected signal';
337
+ case 'rollup':
338
+ return 'Rollup';
339
+ default:
340
+ return 'Context';
341
+ }
342
+ }
343
+
344
+ function diagnosticFactLabel(type: string): string {
345
+ switch (type) {
346
+ case 'explicit_reference':
347
+ return 'Explicit reference';
348
+ case 'owner_rollup':
349
+ return 'Owner rollup';
350
+ case 'selected_backend_issue':
351
+ return 'Selected backend';
352
+ case 'service_config_mismatch':
353
+ return 'Service config';
354
+ case 'service_env_reference':
355
+ return 'Service env';
356
+ case 'probe_target_mismatch':
357
+ return 'Probe target';
358
+ case 'blocked_init_container':
359
+ return 'Init container';
360
+ case 'restart_cause':
361
+ return 'Restart cause';
362
+ default:
363
+ return type.replace(/_/g, ' ');
364
+ }
365
+ }
366
+
262
367
  // Native-tooltip detail for the collapsed-row age chip: absolute onset + last-seen
263
368
  // freshness, the two facts the compact "2h" hides.
264
369
  function ageTitle(issue: Issue): string {
@@ -11,7 +11,7 @@ export {
11
11
  subjectRef,
12
12
  memberRef,
13
13
  } from './types';
14
- export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef } from './types';
14
+ export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueChangeContext } from './types';
15
15
  export {
16
16
  ISSUE_SEVERITY_LABEL,
17
17
  ISSUE_SEVERITY_BADGE_CLASS,
@@ -57,6 +57,34 @@ export interface IssueAffected {
57
57
  nodes?: number;
58
58
  }
59
59
 
60
+ export type IssueDiagnosticRole = 'candidate' | 'affected' | 'rollup' | 'context';
61
+
62
+ export interface IssueDiagnosticIssueRef {
63
+ ref: IssueResourceRef;
64
+ reason?: string;
65
+ category?: string;
66
+ severity?: IssueSeverity;
67
+ }
68
+
69
+ export interface IssueDiagnosticFact {
70
+ type: string;
71
+ message?: string;
72
+ refs?: IssueResourceRef[];
73
+ related_issues?: IssueDiagnosticIssueRef[];
74
+ }
75
+
76
+ export interface IssueDiagnosticContext {
77
+ role?: IssueDiagnosticRole;
78
+ facts?: IssueDiagnosticFact[];
79
+ }
80
+
81
+ export interface IssueChangeContext {
82
+ changed: boolean;
83
+ what?: string;
84
+ when?: string;
85
+ evidence?: string;
86
+ }
87
+
60
88
  /**
61
89
  * A grouped live issue — one row of the triage queue. Subject (kind/group/
62
90
  * namespace/name) is the topmost owner when the rows folded under a workload,
@@ -98,8 +126,11 @@ export interface Issue {
98
126
  count?: number;
99
127
 
100
128
  affected?: IssueAffected;
129
+ owner?: IssueResourceRef;
101
130
  members?: IssueResourceRef[];
102
131
  members_truncated?: boolean;
132
+ diagnostic_context?: IssueDiagnosticContext;
133
+ change_context?: IssueChangeContext;
103
134
 
104
135
  // Pod crash context carried from the representative member.
105
136
  restart_count?: number;
@@ -310,6 +310,16 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
310
310
  { key: 'externalIP', label: 'External', width: 'w-40', hideOnMobile: true },
311
311
  { key: 'age', label: 'Age', width: 'w-24' },
312
312
  ],
313
+ endpointslices: [
314
+ { key: 'name', label: 'Name' },
315
+ { key: 'namespace', label: 'Namespace', width: 'w-48' },
316
+ { key: 'service', label: 'Service', width: 'w-48' },
317
+ { key: 'addressType', label: 'Address Type', width: 'w-28' },
318
+ { key: 'endpoints', label: 'Endpoints', width: 'w-32' },
319
+ { key: 'addresses', label: 'Addresses', width: 'w-24' },
320
+ { key: 'ports', label: 'Ports', width: 'w-40', hideOnMobile: true },
321
+ { key: 'age', label: 'Age', width: 'w-24' },
322
+ ],
313
323
  ingresses: [
314
324
  { key: 'name', label: 'Name', width: 'min-w-40' },
315
325
  { key: 'namespace', label: 'Namespace', width: 'w-36 shrink-0' },
@@ -4509,6 +4519,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
4509
4519
  return <ReplicaSetCell resource={resource} column={column} />
4510
4520
  case 'services':
4511
4521
  return <ServiceCell resource={resource} column={column} />
4522
+ case 'endpointslices':
4523
+ return <EndpointSliceCell resource={resource} column={column} />
4512
4524
  case 'ingresses':
4513
4525
  return <IngressCell resource={resource} column={column} />
4514
4526
  case 'configmaps':
@@ -5333,6 +5345,56 @@ function ServiceCell({ resource, column }: { resource: any; column: string }) {
5333
5345
  }
5334
5346
  }
5335
5347
 
5348
+ function getEndpointSliceReadyCount(resource: any): number {
5349
+ return (resource.endpoints || []).filter((endpoint: any) => endpoint?.conditions?.ready !== false).length
5350
+ }
5351
+
5352
+ function getEndpointSliceAddressCount(resource: any): number {
5353
+ return (resource.endpoints || []).reduce((total: number, endpoint: any) => total + (endpoint.addresses?.length || 0), 0)
5354
+ }
5355
+
5356
+ function getEndpointSlicePorts(resource: any): string {
5357
+ const ports = resource.ports || []
5358
+ if (ports.length === 0) return '-'
5359
+ return ports.map((port: any) => {
5360
+ const name = port.name || 'unnamed'
5361
+ const protocol = port.protocol || 'TCP'
5362
+ return `${name}:${port.port ?? '-'}${protocol !== 'TCP' ? `/${protocol}` : ''}`
5363
+ }).join(', ')
5364
+ }
5365
+
5366
+ function EndpointSliceCell({ resource, column }: { resource: any; column: string }) {
5367
+ switch (column) {
5368
+ case 'service': {
5369
+ const service = resource.metadata?.labels?.['kubernetes.io/service-name']
5370
+ return <span className="text-sm text-theme-text-secondary truncate">{service || '-'}</span>
5371
+ }
5372
+ case 'addressType':
5373
+ return <span className="text-sm text-theme-text-secondary">{resource.addressType || '-'}</span>
5374
+ case 'endpoints': {
5375
+ const total = resource.endpoints?.length || 0
5376
+ const ready = getEndpointSliceReadyCount(resource)
5377
+ const color = total === 0 ? SEVERITY_BADGE.neutral :
5378
+ ready === total ? SEVERITY_BADGE.success :
5379
+ ready > 0 ? SEVERITY_BADGE.warning :
5380
+ SEVERITY_BADGE.error
5381
+ return <span className={clsx('badge', color)}>{ready}/{total}</span>
5382
+ }
5383
+ case 'addresses':
5384
+ return <span className="text-sm text-theme-text-secondary">{getEndpointSliceAddressCount(resource)}</span>
5385
+ case 'ports': {
5386
+ const ports = getEndpointSlicePorts(resource)
5387
+ return (
5388
+ <Tooltip content={ports}>
5389
+ <span className="text-sm text-theme-text-secondary truncate">{ports}</span>
5390
+ </Tooltip>
5391
+ )
5392
+ }
5393
+ default:
5394
+ return <span className="text-sm text-theme-text-tertiary">-</span>
5395
+ }
5396
+ }
5397
+
5336
5398
  function IngressCell({ resource, column }: { resource: any; column: string }) {
5337
5399
  switch (column) {
5338
5400
  case 'class': {
@@ -0,0 +1,141 @@
1
+ import { Radio, Server, Waypoints } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { Section, PropertyList, Property } from '../../ui/drawer-components'
4
+ import type { ResourceRef } from '../../../types'
5
+
6
+ interface EndpointSliceRendererProps {
7
+ data: any
8
+ onNavigate?: (ref: ResourceRef) => void
9
+ }
10
+
11
+ function isEndpointReady(endpoint: any): boolean {
12
+ return endpoint?.conditions?.ready !== false
13
+ }
14
+
15
+ function endpointAddressCount(endpoints: any[]): number {
16
+ return endpoints.reduce((total, endpoint) => total + (endpoint.addresses?.length || 0), 0)
17
+ }
18
+
19
+ function endpointTargetLabel(endpoint: any): string | null {
20
+ const target = endpoint.targetRef
21
+ if (!target?.kind || !target?.name) return null
22
+ return target.namespace ? `${target.kind} ${target.namespace}/${target.name}` : `${target.kind} ${target.name}`
23
+ }
24
+
25
+ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendererProps) {
26
+ const metadata = data.metadata || {}
27
+ const labels = metadata.labels || {}
28
+ const endpoints = data.endpoints || []
29
+ const ports = data.ports || []
30
+ const serviceName = labels['kubernetes.io/service-name']
31
+ const readyCount = endpoints.filter(isEndpointReady).length
32
+ const addresses = endpointAddressCount(endpoints)
33
+
34
+ return (
35
+ <>
36
+ <Section title="EndpointSlice" icon={Radio}>
37
+ <PropertyList>
38
+ <Property label="Address Type" value={data.addressType || '-'} />
39
+ <Property label="Endpoints" value={`${readyCount}/${endpoints.length} ready`} />
40
+ <Property label="Addresses" value={addresses} />
41
+ <Property label="Ports" value={ports.length} />
42
+ {serviceName && (
43
+ <Property
44
+ label="Service"
45
+ value={onNavigate ? (
46
+ <button
47
+ type="button"
48
+ className="text-sm text-accent-text hover:underline font-medium"
49
+ onClick={() => onNavigate({ kind: 'Service', namespace: metadata.namespace, name: serviceName })}
50
+ >
51
+ {serviceName}
52
+ </button>
53
+ ) : serviceName}
54
+ />
55
+ )}
56
+ </PropertyList>
57
+ </Section>
58
+
59
+ {ports.length > 0 && (
60
+ <Section title="Ports" icon={Waypoints}>
61
+ <div className="space-y-2">
62
+ {ports.map((port: any, index: number) => (
63
+ <div key={`${port.name || 'port'}-${port.port || index}-${port.protocol || 'TCP'}`} className="card-inner text-sm">
64
+ <div className="flex items-center justify-between gap-3">
65
+ <div className="min-w-0">
66
+ <div className="font-medium text-theme-text-primary truncate">{port.name || `port-${index + 1}`}</div>
67
+ {port.appProtocol && (
68
+ <div className="text-xs text-theme-text-tertiary mt-0.5">{port.appProtocol}</div>
69
+ )}
70
+ </div>
71
+ <div className="flex items-center gap-2 shrink-0">
72
+ <span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{port.protocol || 'TCP'}</span>
73
+ <span className="font-mono text-theme-text-secondary">{port.port ?? '-'}</span>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ </Section>
80
+ )}
81
+
82
+ {endpoints.length > 0 && (
83
+ <Section title="Endpoints" icon={Server}>
84
+ <div className="space-y-2">
85
+ {endpoints.map((endpoint: any, index: number) => {
86
+ const ready = isEndpointReady(endpoint)
87
+ const targetLabel = endpointTargetLabel(endpoint)
88
+ const target = endpoint.targetRef
89
+ return (
90
+ <div key={`${endpoint.addresses?.join(',') || 'endpoint'}-${index}`} className="card-inner text-sm space-y-3">
91
+ <div className="flex items-start justify-between gap-3">
92
+ <div className="min-w-0 space-y-1">
93
+ <div className="flex flex-wrap items-center gap-1.5">
94
+ {(endpoint.addresses || []).map((address: string) => (
95
+ <span key={address} className="badge-sm bg-theme-elevated text-theme-text-primary border border-theme-border font-mono">
96
+ {address}
97
+ </span>
98
+ ))}
99
+ </div>
100
+ {targetLabel && (
101
+ <button
102
+ type="button"
103
+ className="text-xs text-theme-text-secondary hover:text-accent-text"
104
+ disabled={!onNavigate}
105
+ onClick={() => onNavigate?.({
106
+ kind: target.kind,
107
+ namespace: target.namespace || metadata.namespace,
108
+ name: target.name,
109
+ })}
110
+ >
111
+ {targetLabel}
112
+ </button>
113
+ )}
114
+ </div>
115
+ <div className="flex flex-wrap justify-end gap-1.5 shrink-0">
116
+ <span className={clsx('badge-sm', ready ? 'status-healthy' : 'status-unhealthy')}>
117
+ {ready ? 'Ready' : 'Not Ready'}
118
+ </span>
119
+ {endpoint.conditions?.serving === false && (
120
+ <span className="badge-sm status-degraded">Not Serving</span>
121
+ )}
122
+ {endpoint.conditions?.terminating === true && (
123
+ <span className="badge-sm status-alert">Terminating</span>
124
+ )}
125
+ </div>
126
+ </div>
127
+ {(endpoint.nodeName || endpoint.zone) && (
128
+ <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-theme-text-tertiary">
129
+ {endpoint.nodeName && <span>Node: {endpoint.nodeName}</span>}
130
+ {endpoint.zone && <span>Zone: {endpoint.zone}</span>}
131
+ </div>
132
+ )}
133
+ </div>
134
+ )
135
+ })}
136
+ </div>
137
+ </Section>
138
+ )}
139
+ </>
140
+ )
141
+ }
@@ -1,15 +1,34 @@
1
1
  import { type ReactNode } from 'react'
2
- import { Globe, Clock } from 'lucide-react'
2
+ import { Globe, Clock, Radio } from 'lucide-react'
3
3
  import { Section, PropertyList, Property, KeyValueBadgeList, CopyHandler, AlertBanner } from '../../ui/drawer-components'
4
+ import type { ResourceRef } from '../../../types'
4
5
 
5
6
  interface ServiceRendererProps {
6
7
  data: any
7
8
  onCopy: CopyHandler
8
9
  copied: string | null
10
+ endpointSlices?: any[]
11
+ endpointSlicesLoading?: boolean
12
+ onNavigate?: (ref: ResourceRef) => void
9
13
  renderPortAction?: (props: { namespace: string; serviceName: string; port: number; protocol: string }) => ReactNode
10
14
  }
11
15
 
12
- export function ServiceRenderer({ data, onCopy, copied, renderPortAction }: ServiceRendererProps) {
16
+ function endpointSliceAddressCount(slice: any): number {
17
+ return (slice.endpoints || []).reduce((total: number, endpoint: any) => total + (endpoint.addresses?.length || 0), 0)
18
+ }
19
+
20
+ function endpointSliceReadyCount(slice: any): number {
21
+ return (slice.endpoints || []).filter((endpoint: any) => endpoint?.conditions?.ready !== false).length
22
+ }
23
+
24
+ function endpointSliceReadyClass(ready: number, total: number): string {
25
+ if (total === 0) return 'status-unknown'
26
+ if (ready === total) return 'status-healthy'
27
+ if (ready > 0) return 'status-degraded'
28
+ return 'status-unhealthy'
29
+ }
30
+
31
+ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, onNavigate, renderPortAction }: ServiceRendererProps) {
13
32
  const spec = data.spec || {}
14
33
  const ports = spec.ports || []
15
34
  const lbIngress = data.status?.loadBalancer?.ingress || []
@@ -105,6 +124,49 @@ export function ServiceRenderer({ data, onCopy, copied, renderPortAction }: Serv
105
124
  <KeyValueBadgeList items={spec.selector} />
106
125
  </Section>
107
126
  )}
127
+
128
+ {hasNoSelector && !isExternalName && (
129
+ <Section title="EndpointSlices" icon={Radio}>
130
+ {endpointSlicesLoading ? (
131
+ <div className="text-sm text-theme-text-tertiary">Loading EndpointSlices...</div>
132
+ ) : endpointSlices && endpointSlices.length > 0 ? (
133
+ <div className="space-y-2">
134
+ {endpointSlices.map((slice: any) => {
135
+ const sliceName = slice.metadata?.name
136
+ const endpoints = slice.endpoints || []
137
+ const ready = endpointSliceReadyCount(slice)
138
+ const addresses = endpointSliceAddressCount(slice)
139
+ return (
140
+ <button
141
+ key={slice.metadata?.uid || sliceName}
142
+ type="button"
143
+ className="card-inner w-full text-left hover:bg-theme-hover transition-colors"
144
+ onClick={() => onNavigate?.({
145
+ kind: 'EndpointSlice',
146
+ group: 'discovery.k8s.io',
147
+ namespace,
148
+ name: sliceName,
149
+ })}
150
+ >
151
+ <div className="flex items-center justify-between gap-3">
152
+ <div className="min-w-0">
153
+ <div className="text-sm font-medium text-theme-text-primary truncate">{sliceName}</div>
154
+ <div className="text-xs text-theme-text-tertiary mt-0.5">{slice.addressType || 'Unknown'} address type</div>
155
+ </div>
156
+ <div className="flex flex-wrap items-center justify-end gap-1.5 shrink-0">
157
+ <span className={`badge-sm ${endpointSliceReadyClass(ready, endpoints.length)}`}>{ready}/{endpoints.length} ready</span>
158
+ <span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{addresses} addresses</span>
159
+ </div>
160
+ </div>
161
+ </button>
162
+ )
163
+ })}
164
+ </div>
165
+ ) : (
166
+ <div className="text-sm text-theme-text-tertiary">No EndpointSlices found for this Service.</div>
167
+ )}
168
+ </Section>
169
+ )}
108
170
  </>
109
171
  )
110
172
  }
@@ -25,6 +25,7 @@ export * from './ConfigMapRenderer'
25
25
  export * from './CronJobRenderer'
26
26
  export * from './eso-cells'
27
27
  export * from './EventRenderer'
28
+ export * from './EndpointSliceRenderer'
28
29
  export * from './ExposedSecretReportRenderer'
29
30
  export * from './ExternalSecretRenderer'
30
31
  export * from './flux-cells'
@@ -18,7 +18,7 @@ export interface CreateResourceDialogProps {
18
18
  initialYaml?: string
19
19
  title?: string
20
20
  // Injected by platform (decouples from data-fetching hooks)
21
- onApply: (params: { yaml: string; mode: 'apply' | 'create'; dryRun: boolean }) => Promise<ApplyResult[]>
21
+ onApply: (params: { yaml: string; mode: 'apply' | 'create'; dryRun: boolean; force: boolean }) => Promise<ApplyResult[]>
22
22
  isApplying: boolean
23
23
  /** Called after a successful non-dry-run apply with the first created resource */
24
24
  onCreated?: (result: ApplyResult) => void
@@ -28,6 +28,7 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
28
28
  const [yaml, setYaml] = useState(initialYaml)
29
29
  const [mode, setMode] = useState<'apply' | 'create'>('apply')
30
30
  const [dryRun, setDryRun] = useState(false)
31
+ const [force, setForce] = useState(false)
31
32
  const [yamlValid, setYamlValid] = useState(true)
32
33
  const [error, setError] = useState<string | null>(null)
33
34
  const [success, setSuccess] = useState<string | null>(null)
@@ -38,6 +39,7 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
38
39
  setYaml(initialYaml)
39
40
  setMode('apply')
40
41
  setDryRun(false)
42
+ setForce(false)
41
43
  setError(null)
42
44
  setSuccess(null)
43
45
  }
@@ -64,7 +66,7 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
64
66
  setSuccess(null)
65
67
 
66
68
  try {
67
- const results = await onApply({ yaml, mode, dryRun })
69
+ const results = await onApply({ yaml, mode, dryRun, force: mode === 'apply' && force })
68
70
  const action = mode === 'create' ? 'Created' : 'Applied'
69
71
  const dryRunLabel = dryRun ? ' (dry run)' : ''
70
72
 
@@ -87,7 +89,7 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
87
89
  } catch (err) {
88
90
  setError(err instanceof Error ? err.message : 'Unknown error')
89
91
  }
90
- }, [yaml, mode, dryRun, onApply, onCreated, handleClose])
92
+ }, [yaml, mode, dryRun, force, onApply, onCreated, handleClose])
91
93
 
92
94
  const dialogTitle = title || 'Create Resource'
93
95
  const submitLabel = mode === 'create' ? 'Create' : 'Apply'
@@ -171,6 +173,33 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
171
173
  Dry run
172
174
  </label>
173
175
  </Tooltip>
176
+
177
+ {/* Force checkbox — only meaningful for server-side apply */}
178
+ <Tooltip
179
+ content={
180
+ <div className="space-y-1.5 max-w-[19rem]">
181
+ <p>Override field ownership (server-side apply).</p>
182
+ <p>
183
+ <span className="font-medium text-theme-text-primary">Off (default):</span> if a field is owned by Helm/Flux/Argo/kubectl, the whole apply is rejected with a conflict instead of overwriting.
184
+ </p>
185
+ <p>
186
+ <span className="font-medium text-theme-text-primary">On:</span> your manifest overwrites those fields — but an active controller may reconcile them back on its next sync.
187
+ </p>
188
+ </div>
189
+ }
190
+ position="bottom"
191
+ >
192
+ <label className={`flex items-center gap-1.5 text-xs cursor-pointer ${mode === 'apply' ? 'text-theme-text-secondary' : 'text-theme-text-tertiary cursor-not-allowed'}`}>
193
+ <input
194
+ type="checkbox"
195
+ checked={mode === 'apply' && force}
196
+ disabled={mode !== 'apply'}
197
+ onChange={(e) => setForce(e.target.checked)}
198
+ className="w-3.5 h-3.5 rounded border-theme-border bg-theme-base"
199
+ />
200
+ Force
201
+ </label>
202
+ </Tooltip>
174
203
  </div>
175
204
 
176
205
  <div className="flex items-center gap-2">
@@ -114,7 +114,7 @@ interface EditableYamlViewProps {
114
114
  /** Called after a successful save so the parent can refetch */
115
115
  onSaved?: () => void
116
116
  /** Save handler — injected by the platform wrapper */
117
- onSave?: (params: { kind: string; namespace: string; name: string; yaml: string }) => Promise<void>
117
+ onSave?: (params: { kind: string; namespace: string; name: string; yaml: string; force: boolean }) => Promise<void>
118
118
  /** Whether a save is in progress */
119
119
  isSaving?: boolean
120
120
  /** Error message from the last save attempt */
@@ -139,6 +139,9 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
139
139
  const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
140
140
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
141
141
  const [showErrorDetails, setShowErrorDetails] = useState(false)
142
+ // Default on: the editor resubmits the full live manifest, so an unforced
143
+ // save would conflict on every field owned by Helm/Flux/Argo/a controller.
144
+ const [force, setForce] = useState(true)
142
145
 
143
146
  // Clean up restored draft flag
144
147
  useEffect(() => {
@@ -187,6 +190,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
187
190
  namespace: resource.namespace,
188
191
  name: resource.name,
189
192
  yaml: editedYaml,
193
+ force,
190
194
  })
191
195
  setIsEditing(false)
192
196
  setEditedYaml('')
@@ -194,7 +198,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
194
198
  } catch {
195
199
  // Error handled by caller via saveError prop
196
200
  }
197
- }, [onSave, resource, editedYaml, yamlErrors, onSaved])
201
+ }, [onSave, resource, editedYaml, yamlErrors, onSaved, force])
198
202
 
199
203
  const handleYamlValidate = useCallback((_isValid: boolean, errors: string[]) => {
200
204
  setYamlErrors(errors)
@@ -222,6 +226,31 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
222
226
  <XCircle className="w-3.5 h-3.5" />
223
227
  Cancel
224
228
  </button>
229
+ <Tooltip
230
+ content={
231
+ <div className="space-y-1.5 max-w-[19rem]">
232
+ <p>Override field ownership (server-side apply).</p>
233
+ <p>
234
+ <span className="font-medium text-theme-text-primary">On (default):</span> your edits overwrite fields owned by Helm/Flux/Argo/kubectl — but an active controller may reconcile them back on its next sync.
235
+ </p>
236
+ <p>
237
+ <span className="font-medium text-theme-text-primary">Off:</span> if another manager owns a field you changed, the whole save is rejected with a conflict (nothing is applied).
238
+ </p>
239
+ </div>
240
+ }
241
+ position="bottom"
242
+ >
243
+ <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary cursor-pointer select-none">
244
+ <input
245
+ type="checkbox"
246
+ checked={force}
247
+ disabled={isPending}
248
+ onChange={(e) => setForce(e.target.checked)}
249
+ className="w-3.5 h-3.5 rounded border-theme-border bg-theme-base"
250
+ />
251
+ Force
252
+ </label>
253
+ </Tooltip>
225
254
  <button
226
255
  onClick={handleSaveEdit}
227
256
  disabled={isPending || yamlErrors.length > 0}
@@ -110,6 +110,7 @@ import {
110
110
  RoleBindingRenderer,
111
111
  WebhookConfigRenderer,
112
112
  EventRenderer,
113
+ EndpointSliceRenderer,
113
114
  GenericRenderer,
114
115
  GitRepositoryRenderer,
115
116
  OCIRepositoryRenderer,
@@ -242,6 +243,7 @@ export interface RendererOverrides {
242
243
  }>
243
244
  ServiceRenderer?: React.ComponentType<{
244
245
  data: any; onCopy: CopyHandler; copied: string | null
246
+ onNavigate?: (ref: ResourceRef) => void
245
247
  }>
246
248
  WorkloadRenderer?: React.ComponentType<{
247
249
  kind: string; data: any
@@ -299,7 +301,7 @@ export interface RendererOverrides {
299
301
  // Known resource types with specific renderers (module-level to avoid re-allocation)
300
302
  const KNOWN_KINDS = new Set([
301
303
  'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
302
- 'services', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs',
304
+ 'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs',
303
305
  'hpas', 'horizontalpodautoscalers', 'nodes', 'persistentvolumeclaims',
304
306
  'rollouts', 'certificates', 'workflows', 'persistentvolumes',
305
307
  'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers',
@@ -494,7 +496,8 @@ export function ResourceRendererDispatch({
494
496
  />
495
497
  )}
496
498
  {kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
497
- {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} />}
499
+ {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
500
+ {kind === 'endpointslices' && <EndpointSliceRenderer data={data} onNavigate={onNavigate} />}
498
501
  {kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
499
502
  {kind === 'configmaps' && <ConfigMapRenderer data={data} />}
500
503
  {kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
@@ -697,6 +700,16 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
697
700
  }
698
701
  return getServiceStatus(data)
699
702
  }
703
+ if (k === 'endpointslices') {
704
+ const endpoints = data.endpoints || []
705
+ const ready = endpoints.filter((endpoint: any) => endpoint?.conditions?.ready !== false).length
706
+ const text = endpoints.length === 0 ? 'No endpoints' : `${ready}/${endpoints.length} ready`
707
+ const color = endpoints.length === 0 ? SEVERITY_BADGE.neutral :
708
+ ready === endpoints.length ? SEVERITY_BADGE.success :
709
+ ready > 0 ? SEVERITY_BADGE.warning :
710
+ SEVERITY_BADGE.error
711
+ return { text, color }
712
+ }
700
713
  if (k === 'jobs') return getJobStatus(data)
701
714
  if (k === 'cronjobs') return getCronJobStatus(data)
702
715
  if (k === 'hpas' || k === 'horizontalpodautoscalers') return getHPAStatus(data)
@@ -54,6 +54,8 @@ const KIND: Record<string, string> = {
54
54
 
55
55
  // Networking
56
56
  Service: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
57
+ Endpoints: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
58
+ EndpointSlice: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
57
59
  Internet: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
58
60
  Ingress: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
59
61
  Gateway: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
@@ -64,8 +64,21 @@ export function MiddleEllipsis({ text, className, title, onTruncatedChange }: Mi
64
64
  // ctx.measureText says 173.4px and clientWidth rounds to 173 — the
65
65
  // integer comparison decides we don't fit and middle-truncates a
66
66
  // name that visually would have rendered fine.
67
- const width = node.getBoundingClientRect().width
68
- if (width <= 0) return
67
+ const rawWidth = node.getBoundingClientRect().width
68
+ if (rawWidth <= 0) return
69
+ // getBoundingClientRect() returns the *rendered* width, which a CSS
70
+ // transform on this node or any ancestor scales — e.g. the `scale()` in a
71
+ // menu/popup/dialog entry animation. Measuring during that animation
72
+ // compares a visually-shrunk container against unscaled canvas text
73
+ // (measureText is transform-independent) and middle-truncates a name that
74
+ // actually fits. Worse, a transform never changes the layout box, so the
75
+ // ResizeObserver below doesn't fire to re-measure once the animation
76
+ // settles — the wrong truncation then sticks. Divide out the cumulative
77
+ // ancestor scaleX to recover the unscaled (still subpixel) layout width.
78
+ // With no transform the scale is 1 and this is a no-op, so the just-fits
79
+ // subpixel precision noted above is preserved.
80
+ const scaleX = cumulativeScaleX(node)
81
+ const width = scaleX > 0 ? rawWidth / scaleX : rawWidth
69
82
  const cs = window.getComputedStyle(node)
70
83
  // Include fontStyle in the shorthand so italic faces measure correctly.
71
84
  // If the assignment ever silently fails (malformed family quoting,
@@ -128,6 +141,29 @@ export function MiddleEllipsis({ text, className, title, onTruncatedChange }: Mi
128
141
  )
129
142
  }
130
143
 
144
+ // Cumulative horizontal scale applied to `el` by CSS transforms on it and its
145
+ // ancestors. Used to convert a transform-scaled getBoundingClientRect() width
146
+ // back to the unscaled layout width, so a `scale()` entry animation on an
147
+ // ancestor (menu/popup/dialog) can't make text middle-truncate when it would
148
+ // otherwise fit. Reads the computed transform matrix's scaleX (`a`) at each
149
+ // level — exact for the scale()/translate() animations this guards against
150
+ // (no rotation, where `a` would fold in cos θ). Returns 1 when nothing in the
151
+ // chain is transformed, making the caller a no-op in the common case.
152
+ function cumulativeScaleX(el: HTMLElement | null): number {
153
+ let scale = 1
154
+ for (let cur: HTMLElement | null = el; cur; cur = cur.parentElement) {
155
+ const t = window.getComputedStyle(cur).transform
156
+ if (t && t !== 'none') {
157
+ try {
158
+ scale *= new DOMMatrixReadOnly(t).a
159
+ } catch {
160
+ // Unparseable transform value — skip this level rather than throw.
161
+ }
162
+ }
163
+ }
164
+ return scale
165
+ }
166
+
131
167
  // Binary-search the largest `n` such that `prefix(n) + … + suffix(n)` fits
132
168
  // in `width`. Symmetric on purpose — keeping prefix and suffix balanced is
133
169
  // the cheapest way to preserve the most identifying chars on both ends of
@@ -657,7 +657,7 @@ export function formatKindName(kind: string): string {
657
657
  const k = kind.toLowerCase()
658
658
  const names: Record<string, string> = {
659
659
  pods: 'Pod', deployments: 'Deployment', daemonsets: 'DaemonSet', statefulsets: 'StatefulSet',
660
- replicasets: 'ReplicaSet', services: 'Service', ingresses: 'Ingress',
660
+ replicasets: 'ReplicaSet', services: 'Service', endpointslices: 'EndpointSlice', ingresses: 'Ingress',
661
661
  gateways: 'Gateway', httproutes: 'HTTPRoute', grpcroutes: 'GRPCRoute',
662
662
  tcproutes: 'TCPRoute', tlsroutes: 'TLSRoute', configmaps: 'ConfigMap',
663
663
  secrets: 'Secret', jobs: 'Job', cronjobs: 'CronJob', hpas: 'HPA',
@@ -33,6 +33,7 @@ export const CORE_RESOURCES: APIResource[] = [
33
33
  { group: 'batch', version: 'v1', kind: 'CronJob', name: 'cronjobs', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
34
34
  { group: 'networking.k8s.io', version: 'v1', kind: 'Ingress', name: 'ingresses', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
35
35
  { group: 'networking.k8s.io', version: 'v1', kind: 'NetworkPolicy', name: 'networkpolicies', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
36
+ { group: 'discovery.k8s.io', version: 'v1', kind: 'EndpointSlice', name: 'endpointslices', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
36
37
  { group: 'autoscaling', version: 'v2', kind: 'HorizontalPodAutoscaler', name: 'horizontalpodautoscalers', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
37
38
  { group: '', version: 'v1', kind: 'Event', name: 'events', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
38
39
  { group: 'rbac.authorization.k8s.io', version: 'v1', kind: 'Role', name: 'roles', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
@@ -32,6 +32,11 @@ describe('kindToPlural', () => {
32
32
  expect(pluralToKind('endpoints')).toBe('Endpoints')
33
33
  })
34
34
 
35
+ test('handles EndpointSlice before discovery loads', () => {
36
+ expect(kindToPlural('EndpointSlice')).toBe('endpointslices')
37
+ expect(pluralToKind('endpointslices')).toBe('EndpointSlice')
38
+ })
39
+
35
40
  test('handles kinds ending in ss (Class-suffix)', () => {
36
41
  expect(kindToPlural('StorageClass')).toBe('storageclasses')
37
42
  expect(kindToPlural('IngressClass')).toBe('ingressclasses')
@@ -12,6 +12,7 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
12
12
  pods: 'Pod',
13
13
  services: 'Service',
14
14
  endpoints: 'Endpoints', // already-plural resource name; englishPlural would yield "endpointses"
15
+ endpointslices: 'EndpointSlice',
15
16
  deployments: 'Deployment',
16
17
  daemonsets: 'DaemonSet',
17
18
  statefulsets: 'StatefulSet',
@@ -48,6 +48,25 @@ spec:
48
48
  targetPort: 80
49
49
  type: ClusterIP`,
50
50
 
51
+ EndpointSlice: `apiVersion: discovery.k8s.io/v1
52
+ kind: EndpointSlice
53
+ metadata:
54
+ name: my-service-1
55
+ namespace: default
56
+ labels:
57
+ kubernetes.io/service-name: my-service
58
+ addressType: IPv4
59
+ ports:
60
+ - name: http
61
+ protocol: TCP
62
+ port: 80
63
+ endpoints:
64
+ - addresses:
65
+ - 10.0.0.10
66
+ conditions:
67
+ ready: true
68
+ serving: true`,
69
+
51
70
  ConfigMap: `apiVersion: v1
52
71
  kind: ConfigMap
53
72
  metadata: