@skyhook-io/k8s-ui 1.10.2 → 1.10.4

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.10.2",
3
+ "version": "1.10.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -122,6 +122,43 @@ export interface IssueRowSlotContext {
122
122
  open: boolean;
123
123
  }
124
124
 
125
+ /** Substitutes the SOURCE of the diagnosis prose in the expanded body.
126
+ *
127
+ * For hosts that can diagnose an issue better than the detectors can — today
128
+ * that means an AI investigation that actually read the logs and cross-checked
129
+ * the resource, where `issue.action` is a static template keyed off the reason
130
+ * code ("Open the resource drawer for events, logs, and YAML."). Showing both
131
+ * makes the reader arbitrate between navigation and an answer.
132
+ *
133
+ * Only the PROSE is the host's. The meta line (restarts, timing, change
134
+ * context), the raw detector text, the reason-code eyebrow, tone and spacing
135
+ * stay here — a host that rebuilt those would have to re-implement
136
+ * `issueTiming` and `changeContextText` and would drift from this file.
137
+ *
138
+ * Raw error is deliberately not overridable: it is the verbatim
139
+ * kubelet/containerd string operators grep for, and a summary is not a
140
+ * substitute for it.
141
+ *
142
+ * Pass a field as undefined to leave that section alone — an override with
143
+ * nothing to say is not an improvement over the detector's own text. */
144
+ export interface IssueDiagnosisSource {
145
+ /** Replaces the prose under "What's wrong". The meta line still follows it. */
146
+ cause?: ReactNode;
147
+ /** Replaces the body of "Next step", and makes the section render even when
148
+ * the detector supplied no `action` of its own. */
149
+ nextStep?: ReactNode;
150
+ /** Provenance — who is talking and how stale they are. Rendered once, in the
151
+ * header of the first overridden section, because it is a fact about the
152
+ * source rather than about either section. Name the source in words a reader
153
+ * already knows from elsewhere in the product; an initialism sitting beside a
154
+ * reason code reads as one more terse token, not as a claim about authorship. */
155
+ attribution?: ReactNode;
156
+ /** Section icon for the overridden sections. Usually leave unset: the stock
157
+ * icons carry the section's TONE (warn / fix), which the source doesn't
158
+ * change, and `attribution` is the better place to say who wrote the prose. */
159
+ icon?: ComponentType<{ className?: string }>;
160
+ }
161
+
125
162
  export interface IssueRowProps {
126
163
  issue: Issue;
127
164
  clusterLabel?: (issue: Issue) => string | undefined;
@@ -139,6 +176,13 @@ export interface IssueRowProps {
139
176
  renderBadges?: (ctx: IssueRowSlotContext) => ReactNode;
140
177
  renderMeta?: (ctx: IssueRowSlotContext) => ReactNode;
141
178
  renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
179
+ /** See IssueDiagnosisSource — replaces the diagnosis prose, not its framing. */
180
+ diagnosisSource?: IssueDiagnosisSource;
181
+ /** Appends a section to the end of the expanded body's stack. Unlike
182
+ * `diagnosisSource` this only adds: it lands after Affected resources and
183
+ * inherits the stack's dividers and rhythm, so the host section needs no
184
+ * chrome of its own. */
185
+ renderDetailSection?: (ctx: IssueRowSlotContext) => ReactNode;
142
186
  ResourceLinkIcon?: ComponentType<{ className?: string }>;
143
187
  }
144
188
 
@@ -156,6 +200,8 @@ export function IssueRow({
156
200
  renderBadges,
157
201
  renderMeta,
158
202
  renderActions,
203
+ diagnosisSource,
204
+ renderDetailSection,
159
205
  ResourceLinkIcon = ExternalLink,
160
206
  }: IssueRowProps) {
161
207
  const cluster = clusterLabel?.(issue);
@@ -329,7 +375,7 @@ export function IssueRow({
329
375
  text keeps enough contrast. */}
330
376
  <div className="border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4">
331
377
  <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
332
- <Diagnosis issue={issue} />
378
+ <Diagnosis issue={issue} source={diagnosisSource} />
333
379
  {issue.incident_parent ? (
334
380
  <section className="flex flex-col gap-1">
335
381
  <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
@@ -349,6 +395,7 @@ export function IssueRow({
349
395
  ) : null}
350
396
  <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
351
397
  <AffectedResources issue={issue} hideSubject={hideSubject} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
398
+ {renderDetailSection?.(slotCtx)}
352
399
  </div>
353
400
  </div>
354
401
  </div>
@@ -361,7 +408,12 @@ export function IssueRow({
361
408
  // Diagnosis: WHAT'S WRONG (amber) → NEXT STEP (emerald) → RAW ERROR (monochrome
362
409
  // terminal block). Status/timing facts share one muted meta line under the
363
410
  // diagnosis so the body reads as three beats rather than a stack of one-liners.
364
- function Diagnosis({ issue }: { issue: Issue }) {
411
+ //
412
+ // `source` lets a host supply the prose of the first two beats (see
413
+ // IssueDiagnosisSource). It is a swap of WORDS ONLY: the meta line, the raw
414
+ // error, the reason-code eyebrow and the tones are computed here either way, so
415
+ // an overridden card loses none of the facts an un-overridden one shows.
416
+ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSource }) {
365
417
  const crash =
366
418
  issue.restart_count || issue.last_terminated_reason
367
419
  ? [issue.restart_count ? `${issue.restart_count} restart${issue.restart_count === 1 ? '' : 's'}` : null, issue.last_terminated_reason ? `last exit: ${issue.last_terminated_reason}` : null]
@@ -405,7 +457,36 @@ function Diagnosis({ issue }: { issue: Issue }) {
405
457
  }
406
458
  if (issue.change_context) meta.push(changeContextText(issue.change_context));
407
459
 
408
- const hasNextStep = !!issue.action || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
460
+ // A host's prose, when it has better. Each section falls back independently:
461
+ // supplying a cause and no next step leaves the detector's own action standing,
462
+ // which is the honest outcome when the host found a cause but no fix.
463
+ const sourceCause = source?.cause;
464
+ const sourceNextStep = source?.nextStep;
465
+ const SourceIcon = source?.icon;
466
+
467
+ // Stated once, on whichever overridden section comes first. Marking every
468
+ // overridden section reads as repetition rather than emphasis, and hosts tend
469
+ // to hang a control off the attribution — one that appeared twice in a single
470
+ // card would be a choice the reader has to make between identical options.
471
+ const attributionOn = sourceCause ? 'cause' : sourceNextStep ? 'nextStep' : null;
472
+ const causeAttribution = attributionOn === 'cause' ? source?.attribution : null;
473
+ const nextStepAttribution = attributionOn === 'nextStep' ? source?.attribution : null;
474
+
475
+ // The reason code rides the eyebrow whenever the prose above it isn't the
476
+ // detector's own — true for a parsed cause and for a host's alike, since in
477
+ // both cases the greppable token is otherwise nowhere on an open row.
478
+ const reasonEyebrow = (issue.cause || sourceCause) && issue.reason ? `· ${issue.reason}` : null;
479
+ const causeLabelExtra =
480
+ reasonEyebrow || causeAttribution ? (
481
+ <>
482
+ {reasonEyebrow}
483
+ {reasonEyebrow && causeAttribution ? ' ' : null}
484
+ {causeAttribution}
485
+ </>
486
+ ) : undefined;
487
+
488
+ const hasNextStep =
489
+ !!issue.action || !!sourceNextStep || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
409
490
 
410
491
  return (
411
492
  <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
@@ -414,12 +495,14 @@ function Diagnosis({ issue }: { issue: Issue }) {
414
495
  identifier operators anchor on, and the collapsed header drops it while
415
496
  open. The non-cause branch already leads with it in the prose. */}
416
497
  <CardSection
417
- icon={AlertTriangle}
498
+ icon={sourceCause && SourceIcon ? SourceIcon : AlertTriangle}
418
499
  label="What's wrong"
419
500
  tone="warn"
420
- labelExtra={issue.cause && issue.reason ? `· ${issue.reason}` : undefined}
501
+ labelExtra={causeLabelExtra}
421
502
  >
422
- {issue.cause ? (
503
+ {sourceCause ? (
504
+ <div className="text-sm leading-relaxed text-theme-text-primary">{sourceCause}</div>
505
+ ) : issue.cause ? (
423
506
  <p className="text-sm leading-relaxed text-theme-text-primary">{issue.cause}</p>
424
507
  ) : (
425
508
  <p className="text-sm leading-relaxed text-theme-text-primary">
@@ -427,12 +510,22 @@ function Diagnosis({ issue }: { issue: Issue }) {
427
510
  {headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
428
511
  </p>
429
512
  )}
513
+ {/* Always the detector's, never the host's: restarts, timing and change
514
+ context are measurements, and a narrative account of the issue is not
515
+ a substitute for them. */}
430
516
  {meta.length > 0 ? <p className="text-xs leading-relaxed text-theme-text-tertiary tabular-nums">{meta.join(' · ')}</p> : null}
431
517
  </CardSection>
432
518
 
433
519
  {hasNextStep ? (
434
- <CardSection icon={ArrowRight} label="Next step" tone="fix">
435
- {issue.action ? <CardBody>{issue.action}</CardBody> : null}
520
+ <CardSection
521
+ icon={sourceNextStep && SourceIcon ? SourceIcon : ArrowRight}
522
+ label="Next step"
523
+ tone="fix"
524
+ labelExtra={nextStepAttribution ?? undefined}
525
+ >
526
+ {sourceNextStep ?? (issue.action ? <CardBody>{issue.action}</CardBody> : null)}
527
+ {/* Survives an override: this is a structured one-click fix with an
528
+ apply path, not advice competing with the host's. */}
436
529
  {issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
437
530
  <p className="text-xs text-theme-text-tertiary">
438
531
  Suggested fix: create namespace <code className="inline-code">{issue.remediation_target}</code> — apply it from the GitOps detail page.
@@ -3,7 +3,7 @@
3
3
  // queue's identically-named helpers when both land. Issue-prefixed public
4
4
  // names are safe to surface.
5
5
  export { IssueRow, IssuesView } from './IssuesView';
6
- export type { IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
6
+ export type { IssueDiagnosisSource, IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
7
7
  export { ResourceIssuesSection } from './ResourceIssuesSection';
8
8
  export { issueTiming } from './issue-timing';
9
9
  export type { IssueTimingDisplay, IssueTimingDisplayKind } from './issue-timing';
@@ -7,10 +7,11 @@
7
7
  */
8
8
  import ELK from 'elkjs/lib/elk-api.js'
9
9
  import elkWorkerAlgorithm from 'elkjs/lib/elk-worker.min.js?url'
10
+ import { assetUrl } from '../../utils/asset-url'
10
11
 
11
12
  // Create ELK with explicit worker URL (runs the algorithm in our worker context)
12
13
  const elk = new ELK({
13
- workerUrl: elkWorkerAlgorithm,
14
+ workerUrl: assetUrl(elkWorkerAlgorithm),
14
15
  })
15
16
 
16
17
  // ELK options for laying out nodes within a single group
package/src/types/core.ts CHANGED
@@ -92,6 +92,21 @@ export interface Capabilities {
92
92
  resources?: ResourcePermissions // Per-resource-type permissions
93
93
  authEnabled?: boolean // Auth is enabled on the backend
94
94
  username?: string // Authenticated user's username (when auth enabled)
95
+ // Which Cloud-connect lane this deployment gets. Optional on the wire:
96
+ // older backends don't advertise it — consumers fall back to wizard links.
97
+ cloudConnect?: CloudConnectCapability
98
+ }
99
+
100
+ // CloudConnectCapability picks the Cloud funnel's connect lane: 'driver'
101
+ // means the in-product connect flow can run on this server (local, no auth,
102
+ // no existing tunnel); 'wizard' routes to the Hub's connect wizard at appUrl.
103
+ export interface CloudConnectCapability {
104
+ lane: 'driver' | 'wizard'
105
+ appUrl: string
106
+ // Hub API origin the connect dialog reads its live copy from. Absent means
107
+ // the server decided this deployment must not fetch — consumers render their
108
+ // compiled-in copy instead.
109
+ apiUrl?: string
95
110
  }
96
111
 
97
112
  export interface FeatureCapabilities {
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { assetUrl } from './asset-url'
3
+
4
+ type RuntimeGlobal = typeof globalThis & {
5
+ __RADAR_RUNTIME_CONFIG__?: { assetBase?: string }
6
+ }
7
+
8
+ function setAssetBase(assetBase: string) {
9
+ ;(globalThis as RuntimeGlobal).__RADAR_RUNTIME_CONFIG__ = { assetBase }
10
+ }
11
+
12
+ /**
13
+ * Simulates a Worker scope, where `window` is absent and `location` is the
14
+ * worker script's own URL. jsdom defines `window`, so it has to be removed.
15
+ */
16
+ function asWorkerAt(pathname: string, run: () => void) {
17
+ const realWindow = globalThis.window
18
+ // @ts-expect-error — deliberately emulating a scope without `window`
19
+ delete globalThis.window
20
+ const realLocation = globalThis.location
21
+ Object.defineProperty(globalThis, 'location', {
22
+ value: { pathname },
23
+ configurable: true,
24
+ writable: true,
25
+ })
26
+ try {
27
+ run()
28
+ } finally {
29
+ Object.defineProperty(globalThis, 'location', {
30
+ value: realLocation,
31
+ configurable: true,
32
+ writable: true,
33
+ })
34
+ globalThis.window = realWindow
35
+ }
36
+ }
37
+
38
+ afterEach(() => {
39
+ delete (globalThis as RuntimeGlobal).__RADAR_RUNTIME_CONFIG__
40
+ })
41
+
42
+ describe('assetUrl at the root (no base path)', () => {
43
+ it('leaves absolute paths untouched', () => {
44
+ expect(assetUrl('/images/radar/radar-icon.svg')).toBe('/images/radar/radar-icon.svg')
45
+ })
46
+
47
+ it('makes Vite-relative asset paths absolute', () => {
48
+ expect(assetUrl('./assets/index-abc.js')).toBe('/assets/index-abc.js')
49
+ expect(assetUrl('assets/index-abc.js')).toBe('/assets/index-abc.js')
50
+ })
51
+
52
+ it('unwraps the webpack/Next StaticImageData shape', () => {
53
+ expect(assetUrl({ src: '/_next/static/x.png' })).toBe('/_next/static/x.png')
54
+ })
55
+
56
+ // An app route may contain an "/assets/" segment (e.g. a namespace named
57
+ // "assets"). That must not be mistaken for a base path.
58
+ it('ignores an /assets/ segment in the current app route', () => {
59
+ expect(assetUrl('/images/radar/radar-icon.svg')).toBe('/images/radar/radar-icon.svg')
60
+ })
61
+ })
62
+
63
+ describe('assetUrl under a base path', () => {
64
+ it('prefixes absolute and relative asset paths', () => {
65
+ setAssetBase('/radar')
66
+ expect(assetUrl('/images/radar/radar-icon.svg')).toBe('/radar/images/radar/radar-icon.svg')
67
+ expect(assetUrl('./assets/index-abc.js')).toBe('/radar/assets/index-abc.js')
68
+ expect(assetUrl('assets/index-abc.js')).toBe('/radar/assets/index-abc.js')
69
+ })
70
+
71
+ it('is idempotent for already-prefixed paths', () => {
72
+ setAssetBase('/radar')
73
+ expect(assetUrl('/radar/assets/index-abc.js')).toBe('/radar/assets/index-abc.js')
74
+ })
75
+
76
+ it('leaves protocol-relative URLs alone', () => {
77
+ setAssetBase('/radar')
78
+ expect(assetUrl('//cdn.example.com/x.png')).toBe('//cdn.example.com/x.png')
79
+ })
80
+
81
+ it('handles a nested base path', () => {
82
+ setAssetBase('/tools/radar')
83
+ expect(assetUrl('/favicon.svg')).toBe('/tools/radar/favicon.svg')
84
+ })
85
+ })
86
+
87
+ describe('assetUrl inside a Worker', () => {
88
+ it('recovers the base path from the worker script URL', () => {
89
+ asWorkerAt('/radar/assets/layout.worker-abc.js', () => {
90
+ expect(assetUrl('/images/x.svg')).toBe('/radar/images/x.svg')
91
+ })
92
+ })
93
+
94
+ it('resolves to the root when the worker is served from the root', () => {
95
+ asWorkerAt('/assets/layout.worker-abc.js', () => {
96
+ expect(assetUrl('/images/x.svg')).toBe('/images/x.svg')
97
+ })
98
+ })
99
+
100
+ // The base path may itself contain "/assets/" — split on the last one.
101
+ it('splits on the last /assets/ segment', () => {
102
+ asWorkerAt('/host/assets/radar/assets/layout.worker-abc.js', () => {
103
+ expect(assetUrl('/images/x.svg')).toBe('/host/assets/radar/images/x.svg')
104
+ })
105
+ })
106
+
107
+ it('prefers the injected config over the URL heuristic', () => {
108
+ setAssetBase('/radar')
109
+ asWorkerAt('/somewhere/assets/worker.js', () => {
110
+ expect(assetUrl('/images/x.svg')).toBe('/radar/images/x.svg')
111
+ })
112
+ })
113
+ })
@@ -9,5 +9,37 @@
9
9
  export type AssetImport = string | { src: string }
10
10
 
11
11
  export function assetUrl(asset: AssetImport): string {
12
- return typeof asset === 'string' ? asset : asset.src
12
+ const url = typeof asset === 'string' ? asset : asset.src
13
+ const assetBase = getRuntimeAssetBase()
14
+ if (!assetBase || url.startsWith('//') || url === assetBase || url.startsWith(`${assetBase}/`)) {
15
+ if (url.startsWith('./assets/')) return `/assets/${url.slice('./assets/'.length)}`
16
+ if (url.startsWith('assets/')) return `/${url}`
17
+ return url
18
+ }
19
+ if (url.startsWith('./')) return `${assetBase}/${url.slice(2)}`
20
+ if (!url.startsWith('/') && url.startsWith('assets/')) return `${assetBase}/${url}`
21
+ if (!url.startsWith('/')) return url
22
+ return `${assetBase}${url}`
23
+ }
24
+
25
+ function getRuntimeAssetBase(): string {
26
+ if (typeof globalThis === 'undefined') return ''
27
+ const runtime = (globalThis as typeof globalThis & {
28
+ __RADAR_RUNTIME_CONFIG__?: { assetBase?: string }
29
+ }).__RADAR_RUNTIME_CONFIG__
30
+ const configured = runtime?.assetBase?.replace(/\/+$/, '')
31
+ if (configured) return configured
32
+
33
+ // A Worker has its own global scope, so it never sees the runtime config the
34
+ // server injects into index.html — but its own script URL sits at
35
+ // `<base>/assets/<chunk>.js`, so the base can be recovered from it. Restricted
36
+ // to worker scopes on purpose: on the main thread `pathname` is an app route,
37
+ // which may legitimately contain an "/assets/" segment (a namespace or
38
+ // resource named "assets") and would yield a bogus prefix for every asset.
39
+ if (typeof window !== 'undefined') return ''
40
+ const pathname = globalThis.location?.pathname ?? ''
41
+ // lastIndexOf, not indexOf: the base path itself may contain "/assets/".
42
+ const assetsIndex = pathname.lastIndexOf('/assets/')
43
+ if (assetsIndex > 0) return pathname.slice(0, assetsIndex)
44
+ return ''
13
45
  }
@@ -1,5 +1,6 @@
1
1
  export * from './format'
2
2
  export * from './pluralize'
3
+ export * from './asset-url'
3
4
  export * from './badge-colors'
4
5
  export * from './resource-icons'
5
6
  export * from './navigation'