@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 +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +22 -2
- package/src/components/issues/IssuesView.tsx +105 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/types.ts +31 -0
- package/src/components/resources/ResourcesView.tsx +62 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +141 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +64 -2
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/shared/CreateResourceDialog.tsx +32 -3
- package/src/components/shared/EditableYamlView.tsx +31 -2
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -2
- package/src/components/ui/Badge.tsx +2 -0
- package/src/components/ui/MiddleEllipsis.tsx +38 -2
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +1 -0
- package/src/utils/skeleton-yaml.ts +19 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
68
|
-
if (
|
|
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')
|
package/src/utils/navigation.ts
CHANGED
|
@@ -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:
|