@skyhook-io/radar-app 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/App.tsx +167 -64
- package/src/api/client.ts +197 -11
- package/src/api/rbac.ts +57 -0
- package/src/components/compare/CompareViewRoute.tsx +116 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +76 -0
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/gitops/GitOpsView.tsx +1 -1
- package/src/components/helm/InstallWizard.tsx +5 -5
- package/src/components/helm/ValuesViewer.tsx +3 -39
- package/src/components/home/ClusterHealthCard.tsx +17 -13
- package/src/components/home/HomeView.tsx +18 -2
- package/src/components/home/MCPSetupDialog.tsx +5 -3
- package/src/components/resource/HPACharts.tsx +232 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +151 -434
- package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
- package/src/components/resource/RestartChart.tsx +124 -0
- package/src/components/resource/RightsizingStrip.tsx +167 -0
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/renderers/HPARenderer.tsx +17 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/MyPermissionsDialog.tsx +231 -0
- package/src/components/traffic/TrafficFlowList.tsx +16 -11
- package/src/components/traffic/TrafficGraph.tsx +5 -1
- package/src/components/ui/DiagnosticsOverlay.tsx +127 -8
- package/src/components/workload/WorkloadView.tsx +107 -3
- package/src/context/NavCustomization.tsx +13 -0
- package/src/main.tsx +1 -0
- package/src/monaco-deep.d.ts +8 -0
- package/src/monaco-setup.ts +26 -0
|
@@ -4,7 +4,8 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
5
5
|
import { openExternal } from '../../utils/navigation'
|
|
6
6
|
import { useDiagnostics } from '../../api/client'
|
|
7
|
-
import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase } from '../../api/client'
|
|
7
|
+
import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
|
|
8
|
+
import { getK8sUIPerfSnapshot, type K8sUIPerfSnapshot } from '@skyhook-io/k8s-ui'
|
|
8
9
|
|
|
9
10
|
interface DiagnosticsOverlayProps {
|
|
10
11
|
onClose: () => void
|
|
@@ -31,9 +32,10 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
31
32
|
|
|
32
33
|
const copyToClipboard = useCallback(async (type: 'json' | 'formatted') => {
|
|
33
34
|
if (!data) return
|
|
35
|
+
const frontendPerf = getK8sUIPerfSnapshot()
|
|
34
36
|
const text = type === 'json'
|
|
35
|
-
? JSON.stringify(data, null, 2)
|
|
36
|
-
: formatForGitHub(data)
|
|
37
|
+
? JSON.stringify({ ...data, frontendPerf }, null, 2)
|
|
38
|
+
: formatForGitHub(data, frontendPerf)
|
|
37
39
|
try {
|
|
38
40
|
await navigator.clipboard.writeText(text)
|
|
39
41
|
setCopied(type)
|
|
@@ -46,7 +48,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
46
48
|
|
|
47
49
|
const openBugReport = useCallback(() => {
|
|
48
50
|
if (!data) return
|
|
49
|
-
const body = formatForBugReport(data)
|
|
51
|
+
const body = formatForBugReport(data, getK8sUIPerfSnapshot())
|
|
50
52
|
const url = `https://github.com/skyhook-io/radar/issues/new?labels=bug&body=${encodeURIComponent(body)}`
|
|
51
53
|
if (url.length > 8000) {
|
|
52
54
|
// URL too long for GitHub — copy diagnostics to clipboard and open blank issue
|
|
@@ -116,6 +118,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
116
118
|
<TrafficSection data={data} />
|
|
117
119
|
<PermissionsSection data={data} />
|
|
118
120
|
<APIDiscoverySection data={data} />
|
|
121
|
+
<PerfSection data={data} />
|
|
119
122
|
<RuntimeSection data={data} />
|
|
120
123
|
<ConfigSection data={data} />
|
|
121
124
|
{data.errors && data.errors.length > 0 && (
|
|
@@ -459,6 +462,73 @@ function APIDiscoverySection({ data }: { data: DiagnosticsSnapshot }) {
|
|
|
459
462
|
)
|
|
460
463
|
}
|
|
461
464
|
|
|
465
|
+
function PerfSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
466
|
+
const backend = data.perf
|
|
467
|
+
const frontend = getK8sUIPerfSnapshot()
|
|
468
|
+
if (!backend && frontend.totalLayouts === 0 && frontend.totalStructureKeyComputes === 0) return null
|
|
469
|
+
// Warn when SSE has dropped frames, the topology payload window's p95 exceeds
|
|
470
|
+
// 5 MB, or the frontend ELK layout p95 exceeds 1s — these are the load-bearing
|
|
471
|
+
// thresholds for "the tab is going to feel bad."
|
|
472
|
+
const warn =
|
|
473
|
+
(backend?.sse.totalDrops ?? 0) > 0 ||
|
|
474
|
+
(backend?.topology.payloadBytes.p95 ?? 0) > 5 * 1024 * 1024 ||
|
|
475
|
+
frontend.layoutMs.p95 > 1000
|
|
476
|
+
return (
|
|
477
|
+
<Section title="Performance" warn={warn}>
|
|
478
|
+
{backend && (
|
|
479
|
+
<>
|
|
480
|
+
<Row label="Topology Builds" value={backend.topology.totalBuilds.toLocaleString()} />
|
|
481
|
+
<Row label=" Duration" value={formatSampleDuration(backend.topology.durationUs)} />
|
|
482
|
+
<Row label=" Node Count" value={formatSampleCount(backend.topology.nodeCount)} />
|
|
483
|
+
<Row label=" Edge Count" value={formatSampleCount(backend.topology.edgeCount)} />
|
|
484
|
+
<Row label=" Payload" value={formatSampleBytes(backend.topology.payloadBytes)} warn={backend.topology.payloadBytes.p95 > 5 * 1024 * 1024} />
|
|
485
|
+
<Row label=" Estimated Nodes" value={formatSampleCount(backend.topology.estimatedNodes)} />
|
|
486
|
+
<Row label="SSE Broadcasts" value={backend.sse.totalBroadcasts.toLocaleString()} />
|
|
487
|
+
<Row label="SSE Drops" value={backend.sse.totalDrops.toLocaleString()} warn={backend.sse.totalDrops > 0} />
|
|
488
|
+
</>
|
|
489
|
+
)}
|
|
490
|
+
{(frontend.totalLayouts > 0 || frontend.totalStructureKeyComputes > 0) && (
|
|
491
|
+
<>
|
|
492
|
+
<Row label="Frontend Layouts" value={`${frontend.totalLayouts.toLocaleString()} (skipped ${frontend.totalLayoutsSkipped.toLocaleString()})`} />
|
|
493
|
+
<Row label=" ELK Duration" value={formatFrontendMs(frontend.layoutMs)} warn={frontend.layoutMs.p95 > 1000} />
|
|
494
|
+
<Row label=" Last Rendered" value={`${frontend.lastLayoutNodeCount.toLocaleString()} nodes / ${frontend.lastLayoutEdgeCount.toLocaleString()} edges`} />
|
|
495
|
+
<Row label="Frontend structureKey" value={`${frontend.totalStructureKeyComputes.toLocaleString()} computes`} />
|
|
496
|
+
<Row label=" Duration" value={formatFrontendUs(frontend.structureKeyUs)} />
|
|
497
|
+
</>
|
|
498
|
+
)}
|
|
499
|
+
</Section>
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function formatSampleDuration(w: DiagSampleWindow): string {
|
|
504
|
+
if (w.count === 0) return 'no samples'
|
|
505
|
+
const ms = (us: number) => (us / 1000).toFixed(us < 1000 ? 2 : 1)
|
|
506
|
+
return `last ${ms(w.last)}ms · p50 ${ms(w.p50)} · p95 ${ms(w.p95)} · max ${ms(w.max)}ms (n=${w.count})`
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function formatSampleCount(w: DiagSampleWindow): string {
|
|
510
|
+
if (w.count === 0) return 'no samples'
|
|
511
|
+
return `last ${w.last.toLocaleString()} · p50 ${w.p50.toLocaleString()} · p95 ${w.p95.toLocaleString()} · max ${w.max.toLocaleString()}`
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function formatSampleBytes(w: DiagSampleWindow): string {
|
|
515
|
+
if (w.count === 0) return 'no samples'
|
|
516
|
+
const kb = (b: number) => b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / 1024 / 1024).toFixed(2)}MB`
|
|
517
|
+
return `last ${kb(w.last)} · p50 ${kb(w.p50)} · p95 ${kb(w.p95)} · max ${kb(w.max)}`
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function formatFrontendMs(w: { count: number; last: number; p50: number; p95: number; max: number }): string {
|
|
521
|
+
if (w.count === 0) return 'no samples'
|
|
522
|
+
const fmt = (v: number) => v < 100 ? v.toFixed(1) : Math.round(v).toString()
|
|
523
|
+
return `last ${fmt(w.last)}ms · p50 ${fmt(w.p50)} · p95 ${fmt(w.p95)} · max ${fmt(w.max)}ms (n=${w.count})`
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function formatFrontendUs(w: { count: number; last: number; p50: number; p95: number; max: number }): string {
|
|
527
|
+
if (w.count === 0) return 'no samples'
|
|
528
|
+
const fmt = (v: number) => v < 1000 ? `${v.toFixed(0)}μs` : `${(v / 1000).toFixed(2)}ms`
|
|
529
|
+
return `last ${fmt(w.last)} · p50 ${fmt(w.p50)} · p95 ${fmt(w.p95)} · max ${fmt(w.max)} (n=${w.count})`
|
|
530
|
+
}
|
|
531
|
+
|
|
462
532
|
function RuntimeSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
463
533
|
if (!data.runtime) return null
|
|
464
534
|
const rt = data.runtime
|
|
@@ -484,6 +554,7 @@ function ConfigSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
|
484
554
|
<Row label="History Limit" value={cfg.historyLimit.toLocaleString()} />
|
|
485
555
|
<Row label="MCP Enabled" value={cfg.mcpEnabled ? 'Yes' : 'No'} />
|
|
486
556
|
<Row label="Prometheus URL" value={cfg.hasPrometheusURL ? 'Set' : 'Auto-discover'} />
|
|
557
|
+
<Row label="Prometheus Headers" value={cfg.hasPrometheusHeaders ? 'Set' : 'None'} />
|
|
487
558
|
</Section>
|
|
488
559
|
)
|
|
489
560
|
}
|
|
@@ -509,7 +580,7 @@ function CopyButton({ label, onClick, copied }: { label: string; onClick: () =>
|
|
|
509
580
|
|
|
510
581
|
// --- GitHub-friendly formatting ---
|
|
511
582
|
|
|
512
|
-
function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): string {
|
|
583
|
+
function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot, includeRawJson = true): string {
|
|
513
584
|
const lines: string[] = []
|
|
514
585
|
lines.push(`## Radar Diagnostics`)
|
|
515
586
|
lines.push(``)
|
|
@@ -599,9 +670,26 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
|
|
|
599
670
|
}
|
|
600
671
|
const pending = getPendingInformers(sync)
|
|
601
672
|
if (pending.length > 0) {
|
|
602
|
-
const parts = pending.map((i) =>
|
|
673
|
+
const parts = pending.map((i) => {
|
|
674
|
+
const flags = [i.deferred ? 'deferred' : 'critical', `${i.items.toLocaleString()} items`]
|
|
675
|
+
if (i.forbiddenSeen) flags.push('forbidden')
|
|
676
|
+
if (i.lastError) flags.push(`err: ${i.lastError}`)
|
|
677
|
+
return `${i.kind}(${flags.join(', ')})`
|
|
678
|
+
})
|
|
603
679
|
lines.push(`- **Pending:** ${parts.join(', ')}`)
|
|
604
680
|
}
|
|
681
|
+
// Synced informers that have since hit a watch error or 403 — a count of 0
|
|
682
|
+
// from one of these is a stale/forbidden lister, not an empty cluster.
|
|
683
|
+
const errored = sync.informers.filter((i) => !pending.includes(i) && (i.lastError || i.forbiddenSeen))
|
|
684
|
+
if (errored.length > 0) {
|
|
685
|
+
const parts = errored.map((i) => {
|
|
686
|
+
const flags: string[] = []
|
|
687
|
+
if (i.forbiddenSeen) flags.push('forbidden')
|
|
688
|
+
if (i.lastError) flags.push(`err: ${i.lastError}`)
|
|
689
|
+
return `${i.kind}(${flags.join(', ')})`
|
|
690
|
+
})
|
|
691
|
+
lines.push(`- **Informer errors:** ${parts.join(', ')}`)
|
|
692
|
+
}
|
|
605
693
|
}
|
|
606
694
|
if (inf.watchedCRDs && inf.watchedCRDs.length > 0) {
|
|
607
695
|
lines.push(`- CRDs: ${inf.watchedCRDs.join(', ')}`)
|
|
@@ -639,6 +727,37 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
|
|
|
639
727
|
lines.push(``)
|
|
640
728
|
}
|
|
641
729
|
|
|
730
|
+
if (data.perf || (frontendPerf && (frontendPerf.totalLayouts > 0 || frontendPerf.totalStructureKeyComputes > 0))) {
|
|
731
|
+
lines.push(`### Performance`)
|
|
732
|
+
if (data.perf) {
|
|
733
|
+
const p = data.perf
|
|
734
|
+
const fmtMs = (us: number) => (us / 1000).toFixed(us < 1000 ? 2 : 1)
|
|
735
|
+
const fmtKB = (b: number) => b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / 1024 / 1024).toFixed(2)}MB`
|
|
736
|
+
lines.push(`- Topology Builds: ${p.topology.totalBuilds.toLocaleString()}`)
|
|
737
|
+
if (p.topology.durationUs.count > 0) {
|
|
738
|
+
lines.push(` - Duration (ms): last ${fmtMs(p.topology.durationUs.last)} · p50 ${fmtMs(p.topology.durationUs.p50)} · p95 ${fmtMs(p.topology.durationUs.p95)} · max ${fmtMs(p.topology.durationUs.max)}`)
|
|
739
|
+
lines.push(` - Nodes: last ${p.topology.nodeCount.last} · p95 ${p.topology.nodeCount.p95} · max ${p.topology.nodeCount.max}`)
|
|
740
|
+
lines.push(` - Edges: last ${p.topology.edgeCount.last} · p95 ${p.topology.edgeCount.p95} · max ${p.topology.edgeCount.max}`)
|
|
741
|
+
lines.push(` - Payload: last ${fmtKB(p.topology.payloadBytes.last)} · p95 ${fmtKB(p.topology.payloadBytes.p95)} · max ${fmtKB(p.topology.payloadBytes.max)}`)
|
|
742
|
+
lines.push(` - Estimated Nodes: last ${p.topology.estimatedNodes.last} · p95 ${p.topology.estimatedNodes.p95}`)
|
|
743
|
+
}
|
|
744
|
+
lines.push(`- SSE: ${p.sse.totalBroadcasts.toLocaleString()} broadcasts, ${p.sse.totalDrops.toLocaleString()} drops`)
|
|
745
|
+
}
|
|
746
|
+
if (frontendPerf && (frontendPerf.totalLayouts > 0 || frontendPerf.totalStructureKeyComputes > 0)) {
|
|
747
|
+
const fmt = (v: number) => v < 100 ? v.toFixed(1) : Math.round(v).toString()
|
|
748
|
+
lines.push(`- Frontend Layouts: ${frontendPerf.totalLayouts.toLocaleString()} (${frontendPerf.totalLayoutsSkipped.toLocaleString()} skipped)`)
|
|
749
|
+
if (frontendPerf.layoutMs.count > 0) {
|
|
750
|
+
lines.push(` - ELK (ms): last ${fmt(frontendPerf.layoutMs.last)} · p50 ${fmt(frontendPerf.layoutMs.p50)} · p95 ${fmt(frontendPerf.layoutMs.p95)} · max ${fmt(frontendPerf.layoutMs.max)}`)
|
|
751
|
+
lines.push(` - Last rendered: ${frontendPerf.lastLayoutNodeCount.toLocaleString()} nodes / ${frontendPerf.lastLayoutEdgeCount.toLocaleString()} edges`)
|
|
752
|
+
}
|
|
753
|
+
if (frontendPerf.structureKeyUs.count > 0) {
|
|
754
|
+
const fmtUs = (v: number) => v < 1000 ? `${Math.round(v)}μs` : `${(v / 1000).toFixed(2)}ms`
|
|
755
|
+
lines.push(` - structureKey: ${frontendPerf.totalStructureKeyComputes.toLocaleString()} computes · p50 ${fmtUs(frontendPerf.structureKeyUs.p50)} · p95 ${fmtUs(frontendPerf.structureKeyUs.p95)} · max ${fmtUs(frontendPerf.structureKeyUs.max)}`)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
lines.push(``)
|
|
759
|
+
}
|
|
760
|
+
|
|
642
761
|
if (data.runtime) {
|
|
643
762
|
const rt = data.runtime
|
|
644
763
|
lines.push(`### Runtime`)
|
|
@@ -682,8 +801,8 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
|
|
|
682
801
|
return lines.join('\n')
|
|
683
802
|
}
|
|
684
803
|
|
|
685
|
-
function formatForBugReport(data: DiagnosticsSnapshot): string {
|
|
686
|
-
const diagnostics = formatForGitHub(data, false)
|
|
804
|
+
function formatForBugReport(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot): string {
|
|
805
|
+
const diagnostics = formatForGitHub(data, frontendPerf, false)
|
|
687
806
|
|
|
688
807
|
const lines: string[] = []
|
|
689
808
|
lines.push(`## Describe the bug`)
|
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
fetchJSON,
|
|
24
24
|
} from '../../api/client'
|
|
25
25
|
import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
|
|
26
|
+
import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid'
|
|
27
|
+
import { RestartEventLane } from '../resource/RestartChart'
|
|
28
|
+
import { RightsizingStrip } from '../resource/RightsizingStrip'
|
|
26
29
|
import { useResourceAudit, useResources } from '../../api/client'
|
|
27
30
|
import { AuditAlerts } from '@skyhook-io/k8s-ui'
|
|
28
31
|
import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
|
|
@@ -35,15 +38,30 @@ import { PodRenderer } from '../resources/renderers/PodRenderer'
|
|
|
35
38
|
import { NodeRenderer } from '../resources/renderers/NodeRenderer'
|
|
36
39
|
import { ServiceRenderer } from '../resources/renderers/ServiceRenderer'
|
|
37
40
|
import { WorkloadRenderer } from '../resources/renderers/WorkloadRenderer'
|
|
41
|
+
import { CompositeRenderer } from '../resources/CompositeRenderer'
|
|
42
|
+
import { ServiceAccountRenderer } from '../resources/renderers/ServiceAccountRenderer'
|
|
43
|
+
import { RoleRenderer } from '../resources/renderers/RoleRenderer'
|
|
44
|
+
import { RoleBindingRenderer } from '../resources/renderers/RoleBindingRenderer'
|
|
45
|
+
import { NamespaceRenderer } from '../resources/renderers/NamespaceRenderer'
|
|
46
|
+
import { HPARenderer } from '../resources/renderers/HPARenderer'
|
|
47
|
+
import { PVCRenderer } from '../resources/renderers/PVCRenderer'
|
|
38
48
|
import { CreateResourceDialog } from '../shared/CreateResourceDialog'
|
|
39
49
|
import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
|
|
40
50
|
import { useDesktopDownload } from '../../hooks/useDesktopDownload'
|
|
51
|
+
import { useCompareLauncher } from '../compare/useCompareLauncher'
|
|
52
|
+
import { apiVersionToGroup } from '../../utils/navigation'
|
|
41
53
|
|
|
42
54
|
type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
|
|
43
55
|
|
|
44
56
|
// Stable reference — web renderer wrappers inject platform hooks internally
|
|
45
57
|
const rendererOverrides: RendererOverrides = {
|
|
46
|
-
PodRenderer, NodeRenderer, ServiceRenderer, WorkloadRenderer,
|
|
58
|
+
PodRenderer, NodeRenderer, ServiceRenderer, WorkloadRenderer, CompositeRenderer,
|
|
59
|
+
ServiceAccountRenderer,
|
|
60
|
+
RoleRenderer,
|
|
61
|
+
RoleBindingRenderer,
|
|
62
|
+
NamespaceRenderer,
|
|
63
|
+
HPARenderer,
|
|
64
|
+
PVCRenderer,
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
// ============================================================================
|
|
@@ -323,9 +341,27 @@ export function WorkloadView({
|
|
|
323
341
|
// RBAC
|
|
324
342
|
const canUpdateSecrets = useCanUpdateSecrets()
|
|
325
343
|
const updateResource = useUpdateResource()
|
|
326
|
-
const
|
|
344
|
+
const baseActionsBarProps = useActionsBarProps(kindProp, namespace, name)
|
|
327
345
|
const desktopDownload = useDesktopDownload()
|
|
328
346
|
|
|
347
|
+
const resourceGroup = useMemo(
|
|
348
|
+
() => (resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : undefined),
|
|
349
|
+
[resource?.apiVersion],
|
|
350
|
+
)
|
|
351
|
+
const { onCompareTo, onCompareAcrossClusters, picker: comparePicker } = useCompareLauncher({
|
|
352
|
+
kind: kindProp,
|
|
353
|
+
namespace,
|
|
354
|
+
name,
|
|
355
|
+
// Prefer the URL-supplied group so Compare works even before the resource
|
|
356
|
+
// fetch completes; fall back to the derived group for callers that don't
|
|
357
|
+
// pass one.
|
|
358
|
+
group: rest.group || resourceGroup || undefined,
|
|
359
|
+
})
|
|
360
|
+
const actionsBarProps = useMemo(
|
|
361
|
+
() => ({ ...baseActionsBarProps, onCompareTo, onCompareAcrossClusters }),
|
|
362
|
+
[baseActionsBarProps, onCompareTo, onCompareAcrossClusters],
|
|
363
|
+
)
|
|
364
|
+
|
|
329
365
|
const handleUpdateResource = useCallback(async (params: { kind: string; namespace: string; name: string; yaml: string }) => {
|
|
330
366
|
await updateResource.mutateAsync(params)
|
|
331
367
|
}, [updateResource])
|
|
@@ -384,7 +420,7 @@ export function WorkloadView({
|
|
|
384
420
|
// Render props
|
|
385
421
|
renderLogsTab={(props) => <LogsTabContent {...props} />}
|
|
386
422
|
renderMetricsTab={({ kind, namespace: ns, name: n }) => (
|
|
387
|
-
<
|
|
423
|
+
<MetricsTabContent kind={kind} namespace={ns} name={n} resource={resource} expanded={expanded} />
|
|
388
424
|
)}
|
|
389
425
|
isMetricsAvailable={(kind, res) =>
|
|
390
426
|
isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending')
|
|
@@ -412,6 +448,7 @@ export function WorkloadView({
|
|
|
412
448
|
rest.onNavigateToResource?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
|
|
413
449
|
}}
|
|
414
450
|
/>
|
|
451
|
+
{comparePicker}
|
|
415
452
|
</>
|
|
416
453
|
)
|
|
417
454
|
}
|
|
@@ -651,6 +688,73 @@ function FluxSourceConsumersInner({ sourceKind, namespace, name }: { sourceKind:
|
|
|
651
688
|
)
|
|
652
689
|
}
|
|
653
690
|
|
|
691
|
+
// Drawer mode: single chart + category tabs (compact for ~500px width).
|
|
692
|
+
// Full-screen mode: multi-chart grid so CPU + Memory + Network can be
|
|
693
|
+
// compared side-by-side without tab switching.
|
|
694
|
+
function MetricsTabContent({ kind, namespace, name, resource, expanded }: {
|
|
695
|
+
kind: string
|
|
696
|
+
namespace: string
|
|
697
|
+
name: string
|
|
698
|
+
resource: any
|
|
699
|
+
expanded: boolean
|
|
700
|
+
}) {
|
|
701
|
+
const showRightsizing = expanded && ['Deployment', 'StatefulSet', 'DaemonSet'].includes(kind)
|
|
702
|
+
|
|
703
|
+
if (expanded) {
|
|
704
|
+
return (
|
|
705
|
+
<div className="flex flex-col h-full">
|
|
706
|
+
{showRightsizing && (
|
|
707
|
+
<div className="px-4 pt-4">
|
|
708
|
+
<RightsizingStrip kind={kind} namespace={namespace} name={name} />
|
|
709
|
+
</div>
|
|
710
|
+
)}
|
|
711
|
+
<div className="flex-1 min-h-0">
|
|
712
|
+
<PrometheusChartsGrid
|
|
713
|
+
kind={kind}
|
|
714
|
+
namespace={namespace}
|
|
715
|
+
name={name}
|
|
716
|
+
resource={resource}
|
|
717
|
+
/>
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Drawer fallback: single chart with tabs + restart lane below. The chart's
|
|
724
|
+
// time-range selector is mirrored to the restart lane so they stay aligned.
|
|
725
|
+
return (
|
|
726
|
+
<DrawerMetricsContent
|
|
727
|
+
kind={kind}
|
|
728
|
+
namespace={namespace}
|
|
729
|
+
name={name}
|
|
730
|
+
resource={resource}
|
|
731
|
+
/>
|
|
732
|
+
)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function DrawerMetricsContent({ kind, namespace, name, resource }: {
|
|
736
|
+
kind: string
|
|
737
|
+
namespace: string
|
|
738
|
+
name: string
|
|
739
|
+
resource: any
|
|
740
|
+
}) {
|
|
741
|
+
const [chartRange, setChartRange] = useState<import('../../api/client').PrometheusTimeRange>('1h')
|
|
742
|
+
const showRestartLane = kind !== 'Node'
|
|
743
|
+
|
|
744
|
+
return (
|
|
745
|
+
<div className="flex flex-col h-full">
|
|
746
|
+
<div className="flex-1 min-h-0">
|
|
747
|
+
<PrometheusCharts kind={kind} namespace={namespace} name={name} showEmptyState resource={resource} onTimeRangeChange={setChartRange} />
|
|
748
|
+
</div>
|
|
749
|
+
{showRestartLane && (
|
|
750
|
+
<div className="px-4 pb-4">
|
|
751
|
+
<RestartEventLane kind={kind} namespace={namespace} name={name} range={chartRange} />
|
|
752
|
+
</div>
|
|
753
|
+
)}
|
|
754
|
+
</div>
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
|
|
654
758
|
// FLUX_SOURCE_KIND_BY_LOWER maps lowercase kind (what the inner WorkloadView
|
|
655
759
|
// produces via its plural-to-singular fallback) to the wire-correct
|
|
656
760
|
// PascalCase form that consumers carry in spec.sourceRef.kind. HelmChart is
|
|
@@ -18,6 +18,19 @@ interface NavCustomizationBase {
|
|
|
18
18
|
brandSlot?: ReactNode;
|
|
19
19
|
/** Replaces the ContextSwitcher (kubeconfig-context picker). */
|
|
20
20
|
contextSlot?: ReactNode;
|
|
21
|
+
/**
|
|
22
|
+
* When set, a "Compare across clusters" option is added to the Compare
|
|
23
|
+
* button in resource action bars. The host returns the URL that should
|
|
24
|
+
* be navigated to (via window.location.assign — typically a hub fleet
|
|
25
|
+
* route). Standalone Radar omits this and the compare action stays
|
|
26
|
+
* single-cluster.
|
|
27
|
+
*/
|
|
28
|
+
crossClusterCompareHref?: (ref: {
|
|
29
|
+
kind: string;
|
|
30
|
+
namespace: string;
|
|
31
|
+
name: string;
|
|
32
|
+
group?: string;
|
|
33
|
+
}) => string;
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
/**
|
package/src/main.tsx
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// monaco-editor's package `exports` map ("./*": "./*") doesn't surface type
|
|
2
|
+
// declarations for deep ESM subpaths, so TS can't resolve these imports even
|
|
3
|
+
// though the .js/.d.ts files exist on disk. Re-export the root types for the
|
|
4
|
+
// editor API and declare the YAML grammar as a side-effect-only module.
|
|
5
|
+
declare module 'monaco-editor/esm/vs/editor/editor.api' {
|
|
6
|
+
export * from 'monaco-editor'
|
|
7
|
+
}
|
|
8
|
+
declare module 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Load the Monaco editor from the bundled npm package instead of the default
|
|
2
|
+
// jsdelivr CDN. Without this, @monaco-editor/react fetches the editor at runtime
|
|
3
|
+
// over the network, so the YAML editor never loads in airgapped / offline
|
|
4
|
+
// deployments. Bundling makes the binary fully self-contained.
|
|
5
|
+
//
|
|
6
|
+
// Imported for side effects from main.tsx (Radar's binary entry) only — library
|
|
7
|
+
// consumers (e.g. Radar Hub) keep the default CDN loader unless they opt in.
|
|
8
|
+
//
|
|
9
|
+
// Import the editor API + YAML grammar directly rather than the `monaco-editor`
|
|
10
|
+
// barrel: the barrel pulls in the JSON/CSS/HTML/TypeScript language services,
|
|
11
|
+
// each of which bundles a heavy web worker (the TS one alone is ~7MB) that Radar
|
|
12
|
+
// never uses — it only ever edits YAML.
|
|
13
|
+
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
|
|
14
|
+
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
|
|
15
|
+
import { loader } from '@monaco-editor/react'
|
|
16
|
+
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
|
17
|
+
|
|
18
|
+
// YAML has no dedicated Monaco language worker — the base editor worker covers
|
|
19
|
+
// everything we use, so route every label to it.
|
|
20
|
+
;(self as typeof self & { MonacoEnvironment?: { getWorker(): Worker } }).MonacoEnvironment = {
|
|
21
|
+
getWorker() {
|
|
22
|
+
return new EditorWorker()
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
loader.config({ monaco })
|