@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
package/src/api/rbac.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import type {
|
|
3
|
+
RBACSubjectResponse,
|
|
4
|
+
RBACRoleResponse,
|
|
5
|
+
RBACWhoamiResponse,
|
|
6
|
+
RBACNamespaceResponse,
|
|
7
|
+
} from '@skyhook-io/k8s-ui'
|
|
8
|
+
import { fetchJSON } from './client'
|
|
9
|
+
|
|
10
|
+
// /api/rbac/subject/{kind}/{namespace}/{name} (ServiceAccount)
|
|
11
|
+
// /api/rbac/subject/{kind}/{name} (User/Group — no namespace)
|
|
12
|
+
export function useRBACSubject(kind: 'ServiceAccount' | 'User' | 'Group', namespace: string, name: string, enabled = true) {
|
|
13
|
+
// Subject lookups depend on cluster-wide RBAC. They don't change often,
|
|
14
|
+
// and operators bouncing between Pod/SA pages re-hit the same SA. Use a
|
|
15
|
+
// 15s stale window so cross-page navigation is instant.
|
|
16
|
+
const path =
|
|
17
|
+
kind === 'ServiceAccount'
|
|
18
|
+
? `/rbac/subject/${kind}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`
|
|
19
|
+
: `/rbac/subject/${kind}/${encodeURIComponent(name)}`
|
|
20
|
+
return useQuery<RBACSubjectResponse>({
|
|
21
|
+
queryKey: ['rbac', 'subject', kind, namespace, name],
|
|
22
|
+
queryFn: () => fetchJSON<RBACSubjectResponse>(path),
|
|
23
|
+
enabled: enabled && !!name && (kind !== 'ServiceAccount' || !!namespace),
|
|
24
|
+
staleTime: 15000,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// /api/rbac/role/{kind}/{namespace}/{name} (use "_" for ClusterRole's empty namespace)
|
|
29
|
+
export function useRBACRole(kind: 'Role' | 'ClusterRole', namespace: string, name: string, enabled = true) {
|
|
30
|
+
const nsSegment = kind === 'ClusterRole' ? '_' : encodeURIComponent(namespace)
|
|
31
|
+
return useQuery<RBACRoleResponse>({
|
|
32
|
+
queryKey: ['rbac', 'role', kind, namespace, name],
|
|
33
|
+
queryFn: () => fetchJSON<RBACRoleResponse>(`/rbac/role/${kind}/${nsSegment}/${encodeURIComponent(name)}`),
|
|
34
|
+
enabled: enabled && !!name && (kind !== 'Role' || !!namespace),
|
|
35
|
+
staleTime: 15000,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// /api/rbac/namespace/{namespace}
|
|
40
|
+
export function useRBACNamespace(namespace: string, enabled = true) {
|
|
41
|
+
return useQuery<RBACNamespaceResponse>({
|
|
42
|
+
queryKey: ['rbac', 'namespace', namespace],
|
|
43
|
+
queryFn: () => fetchJSON<RBACNamespaceResponse>(`/rbac/namespace/${encodeURIComponent(namespace)}`),
|
|
44
|
+
enabled: enabled && !!namespace,
|
|
45
|
+
staleTime: 15000,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// /api/rbac/whoami?namespace=<ns>
|
|
50
|
+
export function useRBACWhoami(namespace: string, enabled = true) {
|
|
51
|
+
return useQuery<RBACWhoamiResponse>({
|
|
52
|
+
queryKey: ['rbac', 'whoami', namespace],
|
|
53
|
+
queryFn: () => fetchJSON<RBACWhoamiResponse>(`/rbac/whoami?namespace=${encodeURIComponent(namespace)}`),
|
|
54
|
+
enabled: enabled && !!namespace,
|
|
55
|
+
staleTime: 30000,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import {
|
|
4
|
+
ResourceCompareView,
|
|
5
|
+
CompareResourcePicker,
|
|
6
|
+
parseRef,
|
|
7
|
+
refToParam,
|
|
8
|
+
type CompareResourceRef,
|
|
9
|
+
type CompareSide,
|
|
10
|
+
type CompareSideError,
|
|
11
|
+
} from '@skyhook-io/k8s-ui'
|
|
12
|
+
import { useResource } from '../../api/client'
|
|
13
|
+
import { useTheme } from '../../context/ThemeContext'
|
|
14
|
+
import { useCompareCandidates } from './useCompareCandidates'
|
|
15
|
+
|
|
16
|
+
export function CompareViewRoute() {
|
|
17
|
+
const navigate = useNavigate()
|
|
18
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
19
|
+
const { theme } = useTheme()
|
|
20
|
+
|
|
21
|
+
const kind = (searchParams.get('kind') ?? '').toLowerCase()
|
|
22
|
+
// Matches Radar's repo-wide URL convention. The bare `group` param is
|
|
23
|
+
// reserved for topology grouping mode and gets stripped by App.tsx's URL
|
|
24
|
+
// sync on every non-topology view.
|
|
25
|
+
const group = searchParams.get('apiGroup') ?? undefined
|
|
26
|
+
const aParsed = parseRef(searchParams.get('a'))
|
|
27
|
+
const bParsed = parseRef(searchParams.get('b'))
|
|
28
|
+
|
|
29
|
+
const [pickerOpen, setPickerOpen] = useState<CompareSide | null>(null)
|
|
30
|
+
|
|
31
|
+
const a: CompareResourceRef | null = aParsed ? { kind, namespace: aParsed.namespace, name: aParsed.name, group } : null
|
|
32
|
+
const b: CompareResourceRef | null = bParsed ? { kind, namespace: bParsed.namespace, name: bParsed.name, group } : null
|
|
33
|
+
|
|
34
|
+
const aQuery = useResource<unknown>(a?.kind ?? '', a?.namespace ?? '', a?.name ?? '', a?.group)
|
|
35
|
+
const bQuery = useResource<unknown>(b?.kind ?? '', b?.namespace ?? '', b?.name ?? '', b?.group)
|
|
36
|
+
|
|
37
|
+
const { candidates, isPending: candidatesPending, error: candidatesError } = useCompareCandidates(kind, group, !!pickerOpen)
|
|
38
|
+
|
|
39
|
+
const updateParam = useCallback(
|
|
40
|
+
(next: Record<string, string>) => {
|
|
41
|
+
const params = new URLSearchParams(searchParams)
|
|
42
|
+
for (const [k, v] of Object.entries(next)) params.set(k, v)
|
|
43
|
+
setSearchParams(params, { replace: true })
|
|
44
|
+
},
|
|
45
|
+
[searchParams, setSearchParams],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const handleSwap = useCallback(() => {
|
|
49
|
+
if (!a || !b) return
|
|
50
|
+
updateParam({ a: refToParam(b), b: refToParam(a) })
|
|
51
|
+
}, [a, b, updateParam])
|
|
52
|
+
|
|
53
|
+
const handleClose = useCallback(() => {
|
|
54
|
+
navigate(-1)
|
|
55
|
+
}, [navigate])
|
|
56
|
+
|
|
57
|
+
const handlePick = useCallback(
|
|
58
|
+
(picked: CompareResourceRef) => {
|
|
59
|
+
if (!pickerOpen) return
|
|
60
|
+
updateParam({ [pickerOpen]: refToParam({ namespace: picked.namespace, name: picked.name }) })
|
|
61
|
+
setPickerOpen(null)
|
|
62
|
+
},
|
|
63
|
+
[pickerOpen, updateParam],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (!kind || !a || !b) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex flex-col items-center justify-center h-full text-theme-text-secondary gap-3 p-8">
|
|
69
|
+
<p className="text-sm">This compare link is missing required parameters.</p>
|
|
70
|
+
<button
|
|
71
|
+
onClick={() => navigate('/resources')}
|
|
72
|
+
className="px-3 py-1.5 text-xs font-medium btn-brand rounded-lg"
|
|
73
|
+
>
|
|
74
|
+
Back to resources
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A refetch failure with cached data is not worth shouting about — show the
|
|
81
|
+
// stale data instead of blanking the side with a misleading "failed" banner.
|
|
82
|
+
const errors: CompareSideError[] = []
|
|
83
|
+
if (aQuery.error && !aQuery.data) errors.push({ side: 'a', message: aQuery.error instanceof Error ? aQuery.error.message : String(aQuery.error) })
|
|
84
|
+
if (bQuery.error && !bQuery.data) errors.push({ side: 'b', message: bQuery.error instanceof Error ? bQuery.error.message : String(bQuery.error) })
|
|
85
|
+
|
|
86
|
+
const source = pickerOpen === 'a' ? a : pickerOpen === 'b' ? b : null
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<ResourceCompareView
|
|
91
|
+
a={a}
|
|
92
|
+
b={b}
|
|
93
|
+
aData={aQuery.data}
|
|
94
|
+
bData={bQuery.data}
|
|
95
|
+
errors={errors}
|
|
96
|
+
editorTheme={theme === 'dark' ? 'vs-dark' : 'vs'}
|
|
97
|
+
onSwap={handleSwap}
|
|
98
|
+
onClose={handleClose}
|
|
99
|
+
onChangeA={() => setPickerOpen('a')}
|
|
100
|
+
onChangeB={() => setPickerOpen('b')}
|
|
101
|
+
/>
|
|
102
|
+
{source && pickerOpen && (
|
|
103
|
+
<CompareResourcePicker
|
|
104
|
+
open={true}
|
|
105
|
+
onClose={() => setPickerOpen(null)}
|
|
106
|
+
source={source}
|
|
107
|
+
sourceSide={pickerOpen}
|
|
108
|
+
candidates={candidates}
|
|
109
|
+
loading={candidatesPending}
|
|
110
|
+
error={candidatesError}
|
|
111
|
+
onPick={handlePick}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import type { CompareResourceRef } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useResources } from '../../api/client'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fetch candidates for the compare picker — same kind as the source.
|
|
7
|
+
* Pass `enabled=false` when the picker is closed to avoid hitting the API.
|
|
8
|
+
*/
|
|
9
|
+
export function useCompareCandidates(kind: string, group: string | undefined, enabled: boolean) {
|
|
10
|
+
const query = useResources<{ metadata?: { name?: string; namespace?: string } }>(
|
|
11
|
+
enabled ? kind : '',
|
|
12
|
+
undefined,
|
|
13
|
+
group,
|
|
14
|
+
)
|
|
15
|
+
const candidates: CompareResourceRef[] = useMemo(() => {
|
|
16
|
+
if (!query.data) return []
|
|
17
|
+
return query.data
|
|
18
|
+
.filter(r => r?.metadata?.name)
|
|
19
|
+
.map(r => ({
|
|
20
|
+
kind,
|
|
21
|
+
namespace: r.metadata?.namespace ?? '',
|
|
22
|
+
name: r.metadata!.name!,
|
|
23
|
+
group,
|
|
24
|
+
}))
|
|
25
|
+
}, [query.data, kind, group])
|
|
26
|
+
return { candidates, isPending: query.isPending, error: query.error }
|
|
27
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { CompareResourcePicker, refToParam, type CompareResourceRef } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { useCompareCandidates } from './useCompareCandidates'
|
|
5
|
+
import { useNavCustomization } from '../../context/NavCustomization'
|
|
6
|
+
|
|
7
|
+
interface UseCompareLauncherArgs {
|
|
8
|
+
/** API plural kind (e.g. "deployments") — must match the route segment used by `/api/resources/{kind}`. */
|
|
9
|
+
kind: string
|
|
10
|
+
namespace: string
|
|
11
|
+
name: string
|
|
12
|
+
/** API group for the resource — required for CRDs that collide with core kinds. */
|
|
13
|
+
group?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CompareLauncher {
|
|
17
|
+
/** Wire this to ResourceActionsBar's `onCompareTo` prop. */
|
|
18
|
+
onCompareTo: () => void
|
|
19
|
+
/**
|
|
20
|
+
* Wire this to ResourceActionsBar's `onCompareAcrossClusters` prop. Undefined
|
|
21
|
+
* when the host (NavCustomization.crossClusterCompareHref) hasn't opted in
|
|
22
|
+
* — keeps the standalone Radar experience identical.
|
|
23
|
+
*/
|
|
24
|
+
onCompareAcrossClusters?: () => void
|
|
25
|
+
/** Render this anywhere in the same tree to surface the picker dialog. */
|
|
26
|
+
picker: React.ReactNode
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useCompareLauncher({ kind, namespace, name, group }: UseCompareLauncherArgs): CompareLauncher {
|
|
30
|
+
const navigate = useNavigate()
|
|
31
|
+
const [open, setOpen] = useState(false)
|
|
32
|
+
const kindLower = kind.toLowerCase()
|
|
33
|
+
const { candidates, isPending, error } = useCompareCandidates(kindLower, group, open)
|
|
34
|
+
const { crossClusterCompareHref } = useNavCustomization()
|
|
35
|
+
|
|
36
|
+
const onCompareTo = useCallback(() => setOpen(true), [])
|
|
37
|
+
|
|
38
|
+
const onCompareAcrossClusters = useCallback(() => {
|
|
39
|
+
if (!crossClusterCompareHref) return
|
|
40
|
+
const href = crossClusterCompareHref({ kind: kindLower, namespace, name, group })
|
|
41
|
+
window.location.assign(href)
|
|
42
|
+
}, [crossClusterCompareHref, kindLower, namespace, name, group])
|
|
43
|
+
|
|
44
|
+
const handlePick = useCallback(
|
|
45
|
+
(picked: CompareResourceRef) => {
|
|
46
|
+
setOpen(false)
|
|
47
|
+
const params = new URLSearchParams()
|
|
48
|
+
params.set('kind', kindLower)
|
|
49
|
+
if (group) params.set('apiGroup', group)
|
|
50
|
+
params.set('a', refToParam({ namespace, name }))
|
|
51
|
+
params.set('b', refToParam({ namespace: picked.namespace, name: picked.name }))
|
|
52
|
+
navigate({ pathname: '/compare', search: params.toString() })
|
|
53
|
+
},
|
|
54
|
+
[navigate, kindLower, group, namespace, name],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const source: CompareResourceRef = { kind: kindLower, namespace, name, group }
|
|
58
|
+
|
|
59
|
+
const picker = (
|
|
60
|
+
<CompareResourcePicker
|
|
61
|
+
open={open}
|
|
62
|
+
onClose={() => setOpen(false)}
|
|
63
|
+
source={source}
|
|
64
|
+
candidates={candidates}
|
|
65
|
+
loading={open && isPending}
|
|
66
|
+
error={open ? error : null}
|
|
67
|
+
onPick={handlePick}
|
|
68
|
+
/>
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
onCompareTo,
|
|
73
|
+
onCompareAcrossClusters: crossClusterCompareHref ? onCompareAcrossClusters : undefined,
|
|
74
|
+
picker,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -417,7 +417,7 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {
|
|
|
417
417
|
return (
|
|
418
418
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
419
419
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
420
|
-
<div className="relative dialog max-w-
|
|
420
|
+
<div className="relative dialog max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
|
421
421
|
{/* Header */}
|
|
422
422
|
<div className="flex items-center justify-between p-4 border-b border-theme-border sticky top-0 bg-theme-surface rounded-t-lg">
|
|
423
423
|
<div className="flex items-center gap-2">
|
|
@@ -19,7 +19,7 @@ export function TerminalTab({ namespace, podName, containerName, containers, isA
|
|
|
19
19
|
const response = await fetch(apiUrl(`/pods/${namespace}/${podName}/debug`), {
|
|
20
20
|
method: 'POST',
|
|
21
21
|
headers: { 'Content-Type': 'application/json' },
|
|
22
|
-
body: JSON.stringify({ targetContainer
|
|
22
|
+
body: JSON.stringify({ targetContainer }),
|
|
23
23
|
})
|
|
24
24
|
if (!response.ok) {
|
|
25
25
|
const err = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
@@ -685,7 +685,7 @@ function extractHelmValues(kind: string, resource: any): HelmValuesData | null {
|
|
|
685
685
|
|
|
686
686
|
function safeStringifyYaml(value: unknown): string {
|
|
687
687
|
try {
|
|
688
|
-
return yaml.stringify(value)
|
|
688
|
+
return yaml.stringify(value, { lineWidth: 0 })
|
|
689
689
|
} catch {
|
|
690
690
|
return JSON.stringify(value, null, 2)
|
|
691
691
|
}
|
|
@@ -106,11 +106,11 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
if (baseValues && defaultValues) {
|
|
109
|
-
setValuesYaml(yaml.stringify(deepMerge(baseValues, defaultValues)))
|
|
109
|
+
setValuesYaml(yaml.stringify(deepMerge(baseValues, defaultValues), { lineWidth: 0 }))
|
|
110
110
|
} else if (defaultValues && !baseValues) {
|
|
111
|
-
setValuesYaml(yaml.stringify(defaultValues))
|
|
111
|
+
setValuesYaml(yaml.stringify(defaultValues, { lineWidth: 0 }))
|
|
112
112
|
} else if (baseValues) {
|
|
113
|
-
setValuesYaml(yaml.stringify(baseValues))
|
|
113
|
+
setValuesYaml(yaml.stringify(baseValues, { lineWidth: 0 }))
|
|
114
114
|
}
|
|
115
115
|
}, [localChartDetail?.values, artifactHubDetail?.values, isLocal, defaultValues])
|
|
116
116
|
|
|
@@ -349,7 +349,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
349
349
|
valuesYaml={valuesYaml}
|
|
350
350
|
defaultValuesYaml={
|
|
351
351
|
isLocal
|
|
352
|
-
? (localChartDetail?.values ? yaml.stringify(localChartDetail.values) : '')
|
|
352
|
+
? (localChartDetail?.values ? yaml.stringify(localChartDetail.values, { lineWidth: 0 }) : '')
|
|
353
353
|
: (artifactHubDetail?.values || '')
|
|
354
354
|
}
|
|
355
355
|
/>
|
|
@@ -664,7 +664,7 @@ function ValuesStep({ valuesYaml, setValuesYaml, yamlError, setYamlError, chartD
|
|
|
664
664
|
const ahDetail = chartDetail as ArtifactHubChartDetail | undefined
|
|
665
665
|
|
|
666
666
|
const defaultValues = isLocal
|
|
667
|
-
? (localDetail?.values ? yaml.stringify(localDetail.values) : '')
|
|
667
|
+
? (localDetail?.values ? yaml.stringify(localDetail.values, { lineWidth: 0 }) : '')
|
|
668
668
|
: (ahDetail?.values || '')
|
|
669
669
|
|
|
670
670
|
const hasDefaults = Boolean(defaultValues)
|
|
@@ -320,43 +320,7 @@ function ToggleButton({ showAll, onToggle, disabled }: { showAll: boolean; onTog
|
|
|
320
320
|
)
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
let result = ''
|
|
327
|
-
|
|
328
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
329
|
-
if (value === null || value === undefined) {
|
|
330
|
-
result += `${spaces}${key}: null\n`
|
|
331
|
-
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
|
332
|
-
result += `${spaces}${key}:\n`
|
|
333
|
-
result += jsonToYaml(value as Record<string, unknown>, indent + 1)
|
|
334
|
-
} else if (Array.isArray(value)) {
|
|
335
|
-
result += `${spaces}${key}:\n`
|
|
336
|
-
for (const item of value) {
|
|
337
|
-
if (typeof item === 'object' && item !== null) {
|
|
338
|
-
result += `${spaces}- \n`
|
|
339
|
-
const itemYaml = jsonToYaml(item as Record<string, unknown>, indent + 2)
|
|
340
|
-
result += itemYaml
|
|
341
|
-
} else {
|
|
342
|
-
result += `${spaces}- ${formatValue(item)}\n`
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
} else {
|
|
346
|
-
result += `${spaces}${key}: ${formatValue(value)}\n`
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
return result
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function formatValue(value: unknown): string {
|
|
354
|
-
if (typeof value === 'string') {
|
|
355
|
-
// Quote strings that contain special characters
|
|
356
|
-
if (value.includes(':') || value.includes('#') || value.includes('\n') || value.startsWith(' ') || value.endsWith(' ')) {
|
|
357
|
-
return `"${value.replace(/"/g, '\\"')}"`
|
|
358
|
-
}
|
|
359
|
-
return value
|
|
360
|
-
}
|
|
361
|
-
return String(value)
|
|
323
|
+
function jsonToYaml(obj: Record<string, unknown>): string {
|
|
324
|
+
if (!obj || Object.keys(obj).length === 0) return ''
|
|
325
|
+
return yaml.stringify(obj, { lineWidth: 0 })
|
|
362
326
|
}
|
|
@@ -389,12 +389,14 @@ export function ClusterHealthCard({
|
|
|
389
389
|
<Cpu className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
390
390
|
CPU
|
|
391
391
|
</div>
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
392
|
+
{metricsServerAvailable && (
|
|
393
|
+
<ResourceBar
|
|
394
|
+
label="Used"
|
|
395
|
+
used={formatCPUMillicores(metrics.cpu.usageMillis)}
|
|
396
|
+
total={formatCPUMillicores(metrics.cpu.capacityMillis)}
|
|
397
|
+
percent={metrics.cpu.usagePercent}
|
|
398
|
+
/>
|
|
399
|
+
)}
|
|
398
400
|
<ResourceBar
|
|
399
401
|
label="Requested"
|
|
400
402
|
used={formatCPUMillicores(metrics.cpu.requestsMillis)}
|
|
@@ -409,12 +411,14 @@ export function ClusterHealthCard({
|
|
|
409
411
|
<MemoryStick className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
410
412
|
Memory
|
|
411
413
|
</div>
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
414
|
+
{metricsServerAvailable && (
|
|
415
|
+
<ResourceBar
|
|
416
|
+
label="Used"
|
|
417
|
+
used={formatMemoryMiB(metrics.memory.usageMillis)}
|
|
418
|
+
total={formatMemoryMiB(metrics.memory.capacityMillis)}
|
|
419
|
+
percent={metrics.memory.usagePercent}
|
|
420
|
+
/>
|
|
421
|
+
)}
|
|
418
422
|
<ResourceBar
|
|
419
423
|
label="Requested"
|
|
420
424
|
used={formatMemoryMiB(metrics.memory.requestsMillis)}
|
|
@@ -423,7 +427,7 @@ export function ClusterHealthCard({
|
|
|
423
427
|
/>
|
|
424
428
|
</div>
|
|
425
429
|
)}
|
|
426
|
-
{!
|
|
430
|
+
{!metricsServerAvailable && (
|
|
427
431
|
<MetricsUnavailableHint platform={cluster.platform} metricsServerAvailable={metricsServerAvailable} />
|
|
428
432
|
)}
|
|
429
433
|
</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
1
2
|
import { useDashboard, useDashboardCRDs, useDashboardHelm } from '../../api/client'
|
|
2
3
|
import type { DashboardResponse } from '../../api/client'
|
|
3
4
|
import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
|
|
@@ -25,6 +26,21 @@ interface HomeViewProps {
|
|
|
25
26
|
|
|
26
27
|
export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource }: HomeViewProps) {
|
|
27
28
|
const { data, isLoading, error } = useDashboard(namespaces)
|
|
29
|
+
|
|
30
|
+
// SSE is cluster-wide on small/medium clusters; the picker only narrows the
|
|
31
|
+
// dashboard summary, so re-apply the filter here or the legend disagrees.
|
|
32
|
+
const scopedTopology = useMemo<Topology | null>(() => {
|
|
33
|
+
if (!topology) return null
|
|
34
|
+
if (namespaces.length === 0) return topology
|
|
35
|
+
const nsSet = new Set(namespaces)
|
|
36
|
+
const nodes = topology.nodes.filter(n => {
|
|
37
|
+
const ns = n.data.namespace as string | undefined
|
|
38
|
+
return !ns || nsSet.has(ns)
|
|
39
|
+
})
|
|
40
|
+
const nodeIds = new Set(nodes.map(n => n.id))
|
|
41
|
+
const edges = topology.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
|
42
|
+
return { nodes, edges }
|
|
43
|
+
}, [topology, namespaces])
|
|
28
44
|
// CRDs and Helm load lazily after main dashboard to keep initial load fast
|
|
29
45
|
const { data: crdsData } = useDashboardCRDs(namespaces)
|
|
30
46
|
const { data: helmData } = useDashboardHelm(namespaces)
|
|
@@ -100,7 +116,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
100
116
|
{/* Primary cards — 2-col grid */}
|
|
101
117
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
102
118
|
<TopologyPreview
|
|
103
|
-
topology={
|
|
119
|
+
topology={scopedTopology}
|
|
104
120
|
summary={data.topologySummary}
|
|
105
121
|
onNavigate={() => onNavigateToView('topology')}
|
|
106
122
|
/>
|
|
@@ -110,7 +126,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
110
126
|
/>
|
|
111
127
|
<ActivitySummary
|
|
112
128
|
namespaces={namespaces}
|
|
113
|
-
topology={
|
|
129
|
+
topology={scopedTopology}
|
|
114
130
|
onNavigate={() => onNavigateToView('timeline')}
|
|
115
131
|
/>
|
|
116
132
|
<TrafficSummary
|
|
@@ -306,11 +306,13 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
|
|
|
306
306
|
{ name: 'kind', required: true, desc: 'resource kind, e.g. pods, deployments, services' },
|
|
307
307
|
{ name: 'namespace', required: false, desc: 'filter to a specific namespace' },
|
|
308
308
|
]},
|
|
309
|
-
{ name: 'get_resource', desc: 'Get
|
|
309
|
+
{ name: 'get_resource', desc: 'Get a single Kubernetes resource: minified spec/status/metadata plus default-on resourceContext (managedBy, exposes, selectedBy, uses, runsOn, issue/audit rollups). Optionally include heavier sidecars (events, metrics, logs).', params: [
|
|
310
310
|
{ name: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service' },
|
|
311
|
-
{ name: 'namespace', required:
|
|
311
|
+
{ name: 'namespace', required: false, desc: 'omit for cluster-scoped kinds (Node, ClusterRole, IngressClass, etc.)' },
|
|
312
312
|
{ name: 'name', required: true, desc: 'resource name' },
|
|
313
|
-
{ name: '
|
|
313
|
+
{ name: 'group', required: false, desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev for Knative Service vs core Service)' },
|
|
314
|
+
{ name: 'include', required: false, desc: 'events, metrics, logs' },
|
|
315
|
+
{ name: 'context', required: false, desc: 'resourceContext tier: basic (default) or none (bare minified)' },
|
|
314
316
|
]},
|
|
315
317
|
{ name: 'get_topology', desc: 'Get the topology graph showing relationships between Kubernetes resources. Returns nodes and edges representing Deployments, Services, Ingresses, Pods, etc. Use \'traffic\' view for network flow or \'resources\' view for ownership hierarchy. Use \'summary\' format for LLM-friendly text descriptions.', params: [
|
|
316
318
|
{ name: 'namespace', required: false, desc: 'filter to a specific namespace' },
|