@skyhook-io/radar-app 1.6.0 → 1.6.2
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 +3 -2
- package/src/App.tsx +19 -22
- package/src/api/client.ts +29 -16
- package/src/components/gitops/GitOpsView.tsx +13 -6
- package/src/components/helm/ChartBrowser.tsx +1 -1
- package/src/components/helm/RoleGatedPanel.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +12 -10
- package/src/components/home/HomeView.tsx +204 -56
- package/src/components/resources/ResourceDetailDrawer.tsx +3 -0
- package/src/components/resources/ResourcesView.tsx +46 -8
- package/src/components/timeline/TimelineList.tsx +6 -1
- package/src/components/timeline/TimelineView.tsx +9 -0
- package/src/components/ui/Omnibar.tsx +225 -115
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/index.ts +15 -0
- package/src/main.tsx +6 -1
- package/src/monaco-setup.ts +17 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"vite": "^8.0.12"
|
|
76
76
|
},
|
|
77
77
|
"sideEffects": [
|
|
78
|
-
"*.css"
|
|
78
|
+
"*.css",
|
|
79
|
+
"./src/monaco-setup.ts"
|
|
79
80
|
]
|
|
80
81
|
}
|
package/src/App.tsx
CHANGED
|
@@ -56,7 +56,8 @@ import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
|
56
56
|
import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
|
|
57
57
|
import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
58
58
|
import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
|
|
59
|
-
import {
|
|
59
|
+
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
60
|
+
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
60
61
|
import type { ContextSwitcherHandle } from './components/ContextSwitcher'
|
|
61
62
|
|
|
62
63
|
// All possible node kinds (core + GitOps)
|
|
@@ -541,6 +542,10 @@ function AppInner() {
|
|
|
541
542
|
description: 'Show keyboard shortcuts',
|
|
542
543
|
category: 'General' as const,
|
|
543
544
|
scope: 'global' as const,
|
|
545
|
+
// Radar owns the shortcut registry even in a chromeless embed, so its `?`
|
|
546
|
+
// overlay is the one that actually lists the working shortcuts. The host
|
|
547
|
+
// (Radar Hub) drives it from its own chrome by dispatching a `?` keydown —
|
|
548
|
+
// it has no registry of its own to populate a competing overlay with.
|
|
544
549
|
handler: () => setShowHelp(prev => !prev),
|
|
545
550
|
},
|
|
546
551
|
{
|
|
@@ -550,8 +555,10 @@ function AppInner() {
|
|
|
550
555
|
category: 'General' as const,
|
|
551
556
|
scope: 'global' as const,
|
|
552
557
|
allowInInputs: true,
|
|
553
|
-
// Standalone focuses the top-center omnibar; embedded opens the modal.
|
|
554
|
-
|
|
558
|
+
// Standalone focuses the top-center omnibar; embedded opens the modal. In
|
|
559
|
+
// a chromeless embed the HOST owns ⌘K (its own omnibar), so do nothing —
|
|
560
|
+
// otherwise both the host omnibar and Radar's palette fire on one ⌘K.
|
|
561
|
+
handler: () => { if (showNavRail) omnibarRef.current?.focus(); else if (!chromeless) setShowCommandPalette(true) },
|
|
555
562
|
},
|
|
556
563
|
{
|
|
557
564
|
id: 'diagnostics',
|
|
@@ -1130,7 +1137,7 @@ function AppInner() {
|
|
|
1130
1137
|
// Fleet mode overrides visible kinds to show only CAPI resources + Node
|
|
1131
1138
|
const effectiveKinds = topologyMode === 'fleet' ? FLEET_MODE_KINDS : visibleKinds
|
|
1132
1139
|
|
|
1133
|
-
// Filter by namespace (
|
|
1140
|
+
// Filter by namespace (client-side) and by visible kinds
|
|
1134
1141
|
const nsSet = namespaces.length > 0 ? new Set(namespaces) : null
|
|
1135
1142
|
const filteredNodes = displayedTopology.nodes.filter(node =>
|
|
1136
1143
|
effectiveKinds.has(node.kind) &&
|
|
@@ -1352,7 +1359,7 @@ function AppInner() {
|
|
|
1352
1359
|
Fills the space the pill bar left; embedded keeps the pills + modal. */}
|
|
1353
1360
|
{showNavRail && (
|
|
1354
1361
|
<div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
|
|
1355
|
-
<
|
|
1362
|
+
<RadarOmnibar
|
|
1356
1363
|
ref={omnibarRef}
|
|
1357
1364
|
onNavigateView={(view) => setMainView(view)}
|
|
1358
1365
|
onNavigateKind={(kind, group) => {
|
|
@@ -1603,21 +1610,7 @@ function AppInner() {
|
|
|
1603
1610
|
console.debug('[filters] App.onNavigateToResourceKind: navigating to', targetURL)
|
|
1604
1611
|
navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
|
|
1605
1612
|
}}
|
|
1606
|
-
onNavigateToResource={
|
|
1607
|
-
// Switch to resources view and open the resource detail drawer
|
|
1608
|
-
setSelectedResource(resource)
|
|
1609
|
-
const newParams = new URLSearchParams(searchParams)
|
|
1610
|
-
newParams.delete('kind') // kind is now in the path
|
|
1611
|
-
newParams.delete('mode')
|
|
1612
|
-
newParams.delete('group')
|
|
1613
|
-
newParams.delete('resource')
|
|
1614
|
-
if (resource.group) {
|
|
1615
|
-
newParams.set('apiGroup', resource.group)
|
|
1616
|
-
} else {
|
|
1617
|
-
newParams.delete('apiGroup')
|
|
1618
|
-
}
|
|
1619
|
-
navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
|
|
1620
|
-
}}
|
|
1613
|
+
onNavigateToResource={navigateFromIssue}
|
|
1621
1614
|
/>
|
|
1622
1615
|
)}
|
|
1623
1616
|
|
|
@@ -1851,6 +1844,9 @@ function AppInner() {
|
|
|
1851
1844
|
<ResourceDetailDrawer
|
|
1852
1845
|
resource={drawerResource}
|
|
1853
1846
|
initialTab={drawerInitialTab}
|
|
1847
|
+
// No Radar header in chromeless embeds (Radar Hub) — anchor the drawer
|
|
1848
|
+
// to the top of the content area instead of leaving a 49px gap.
|
|
1849
|
+
headerHeight={chromeless ? 0 : undefined}
|
|
1854
1850
|
isOpen={resourceDrawer.isOpen}
|
|
1855
1851
|
expanded={drawerExpanded}
|
|
1856
1852
|
onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
|
|
@@ -1964,8 +1960,9 @@ function AppInner() {
|
|
|
1964
1960
|
/>
|
|
1965
1961
|
<MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
|
|
1966
1962
|
|
|
1967
|
-
{/* Debug overlay
|
|
1968
|
-
|
|
1963
|
+
{/* Debug overlay — dev mode, standalone only. Embedded hosts (Radar Hub)
|
|
1964
|
+
own their own dev tooling; ours would collide with theirs bottom-right. */}
|
|
1965
|
+
{import.meta.env.DEV && showNavRail && <DebugOverlay />}
|
|
1969
1966
|
</div>
|
|
1970
1967
|
</div>
|
|
1971
1968
|
</PortForwardProvider>
|
package/src/api/client.ts
CHANGED
|
@@ -122,7 +122,7 @@ export interface DashboardProblem {
|
|
|
122
122
|
namespace: string
|
|
123
123
|
name: string
|
|
124
124
|
group?: string
|
|
125
|
-
severity: 'critical' | 'high' | 'medium'
|
|
125
|
+
severity: 'critical' | 'high' | 'medium' | 'warning' | 'info'
|
|
126
126
|
reason: string
|
|
127
127
|
message: string
|
|
128
128
|
age: string
|
|
@@ -341,8 +341,9 @@ export function useAudit(namespaces: string[] = []) {
|
|
|
341
341
|
|
|
342
342
|
// Live cluster Issues — the grouped triage queue (radar's /api/issues =
|
|
343
343
|
// internal/issues.Compose+Classify+Group). Single-cluster here; the Hub fleet
|
|
344
|
-
// view fans the same shape across clusters.
|
|
345
|
-
//
|
|
344
|
+
// view fans the same shape across clusters. Do not carry old data across query
|
|
345
|
+
// keys: issues are scope-sensitive, so namespace changes must not show the
|
|
346
|
+
// previous scope's rows while the new scope fetches.
|
|
346
347
|
// total = rows returned (after the cap); total_matched = rows that matched
|
|
347
348
|
// before the cap. total_matched > total means the queue was truncated — surface
|
|
348
349
|
// that honestly rather than presenting a capped list as if it were complete.
|
|
@@ -365,7 +366,6 @@ export function useIssues(namespaces: string[] = []) {
|
|
|
365
366
|
queryFn: () => fetchJSON(`/issues${params}`),
|
|
366
367
|
staleTime: 30000,
|
|
367
368
|
refetchInterval: 30000,
|
|
368
|
-
placeholderData: (prev) => prev,
|
|
369
369
|
})
|
|
370
370
|
}
|
|
371
371
|
|
|
@@ -717,6 +717,11 @@ export interface SearchHit {
|
|
|
717
717
|
name: string
|
|
718
718
|
matched?: SearchMatchedField[]
|
|
719
719
|
summaryContext?: SearchSummaryContext
|
|
720
|
+
/** Embedder (Radar Hub) only: the cluster this hit belongs to, for
|
|
721
|
+
* cross-cluster fleet search. Standalone Radar (single-cluster) leaves these
|
|
722
|
+
* unset — the omnibar keys + displays the cluster only when present. */
|
|
723
|
+
cluster?: string
|
|
724
|
+
clusterName?: string
|
|
720
725
|
}
|
|
721
726
|
|
|
722
727
|
export interface SearchResult {
|
|
@@ -817,7 +822,7 @@ export function useAuthMe() {
|
|
|
817
822
|
}
|
|
818
823
|
|
|
819
824
|
// Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
|
|
820
|
-
// CloudRole.AtLeast — the
|
|
825
|
+
// CloudRole.AtLeast — the frontend must agree with the backend on what
|
|
821
826
|
// "member-or-higher" means; otherwise we'd hide a button the
|
|
822
827
|
// backend would happily honor (or vice versa).
|
|
823
828
|
const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
|
|
@@ -1036,6 +1041,7 @@ export interface UseChangesOptions {
|
|
|
1036
1041
|
filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
|
|
1037
1042
|
includeK8sEvents?: boolean
|
|
1038
1043
|
includeManaged?: boolean
|
|
1044
|
+
includeDeleted?: boolean
|
|
1039
1045
|
limit?: number
|
|
1040
1046
|
enabled?: boolean
|
|
1041
1047
|
}
|
|
@@ -1060,7 +1066,7 @@ function getTimeRangeDate(range: TimeRange): Date | null {
|
|
|
1060
1066
|
}
|
|
1061
1067
|
|
|
1062
1068
|
export function useChanges(options: UseChangesOptions = {}) {
|
|
1063
|
-
const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, limit = 200, enabled = true } = options
|
|
1069
|
+
const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, includeDeleted = true, limit = 200, enabled = true } = options
|
|
1064
1070
|
|
|
1065
1071
|
const params = new URLSearchParams()
|
|
1066
1072
|
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
@@ -1068,6 +1074,7 @@ export function useChanges(options: UseChangesOptions = {}) {
|
|
|
1068
1074
|
if (filter) params.set('filter', filter)
|
|
1069
1075
|
if (!includeK8sEvents) params.set('include_k8s_events', 'false')
|
|
1070
1076
|
if (includeManaged) params.set('include_managed', 'true')
|
|
1077
|
+
if (!includeDeleted) params.set('include_deleted', 'false')
|
|
1071
1078
|
params.set('limit', String(limit))
|
|
1072
1079
|
|
|
1073
1080
|
const sinceDate = getTimeRangeDate(timeRange)
|
|
@@ -1078,7 +1085,7 @@ export function useChanges(options: UseChangesOptions = {}) {
|
|
|
1078
1085
|
const queryString = params.toString()
|
|
1079
1086
|
|
|
1080
1087
|
return useQuery<TimelineEvent[]>({
|
|
1081
|
-
queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, limit],
|
|
1088
|
+
queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
|
|
1082
1089
|
queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
|
|
1083
1090
|
staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
|
|
1084
1091
|
refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
|
|
@@ -1129,6 +1136,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
1129
1136
|
const p = new URLSearchParams()
|
|
1130
1137
|
p.set('namespace', namespace)
|
|
1131
1138
|
p.set('kind', singularKind)
|
|
1139
|
+
p.set('name', name)
|
|
1132
1140
|
p.set('include_managed', 'true')
|
|
1133
1141
|
p.set('since', since)
|
|
1134
1142
|
return p
|
|
@@ -1145,8 +1153,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
1145
1153
|
const params = baseParams()
|
|
1146
1154
|
params.set('sources', 'k8s_event')
|
|
1147
1155
|
params.set('limit', '500')
|
|
1148
|
-
|
|
1149
|
-
return events.filter(e => e.name === name)
|
|
1156
|
+
return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
|
|
1150
1157
|
},
|
|
1151
1158
|
enabled,
|
|
1152
1159
|
refetchInterval: 15000,
|
|
@@ -1160,8 +1167,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
1160
1167
|
const params = baseParams()
|
|
1161
1168
|
params.set('sources', 'informer,historical')
|
|
1162
1169
|
params.set('limit', '50')
|
|
1163
|
-
|
|
1164
|
-
return events.filter(e => e.name === name)
|
|
1170
|
+
return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
|
|
1165
1171
|
},
|
|
1166
1172
|
enabled,
|
|
1167
1173
|
refetchInterval: 15000,
|
|
@@ -1305,21 +1311,28 @@ export interface TopNodeMetrics {
|
|
|
1305
1311
|
memoryAllocatable: number // bytes
|
|
1306
1312
|
}
|
|
1307
1313
|
|
|
1308
|
-
// Fetch bulk metrics for
|
|
1309
|
-
export function useTopPodMetrics() {
|
|
1314
|
+
// Fetch bulk metrics for pods (for CPU/Memory columns in resource table)
|
|
1315
|
+
export function useTopPodMetrics(options?: { enabled?: boolean; namespaces?: string[] }) {
|
|
1316
|
+
const namespacesParam = options?.namespaces?.join(',') ?? ''
|
|
1317
|
+
const params = new URLSearchParams()
|
|
1318
|
+
if (namespacesParam) params.set('namespaces', namespacesParam)
|
|
1319
|
+
const queryString = params.toString()
|
|
1320
|
+
|
|
1310
1321
|
return useQuery<TopPodMetrics[]>({
|
|
1311
|
-
queryKey: ['top-pod-metrics'],
|
|
1312
|
-
queryFn: () => fetchJSON(
|
|
1322
|
+
queryKey: ['top-pod-metrics', namespacesParam],
|
|
1323
|
+
queryFn: () => fetchJSON(`/metrics/top/pods${queryString ? `?${queryString}` : ''}`),
|
|
1324
|
+
enabled: options?.enabled ?? true,
|
|
1313
1325
|
staleTime: 25000,
|
|
1314
1326
|
refetchInterval: 30000,
|
|
1315
1327
|
})
|
|
1316
1328
|
}
|
|
1317
1329
|
|
|
1318
1330
|
// Fetch bulk metrics for all nodes (for CPU/Memory columns in resource table)
|
|
1319
|
-
export function useTopNodeMetrics() {
|
|
1331
|
+
export function useTopNodeMetrics(options?: { enabled?: boolean }) {
|
|
1320
1332
|
return useQuery<TopNodeMetrics[]>({
|
|
1321
1333
|
queryKey: ['top-node-metrics'],
|
|
1322
1334
|
queryFn: () => fetchJSON('/metrics/top/nodes'),
|
|
1335
|
+
enabled: options?.enabled ?? true,
|
|
1323
1336
|
staleTime: 25000,
|
|
1324
1337
|
refetchInterval: 30000,
|
|
1325
1338
|
})
|
|
@@ -83,6 +83,7 @@ const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
|
|
|
83
83
|
interface ResourceCountsResponse {
|
|
84
84
|
counts: Record<string, number>
|
|
85
85
|
forbidden?: string[]
|
|
86
|
+
unavailable?: string[]
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
interface GitOpsViewProps {
|
|
@@ -136,9 +137,15 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
136
137
|
initNavigationMap([...(apiResources ?? []), ...GITOPS_KINDS])
|
|
137
138
|
}, [apiResources])
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
const hasGitOpsRowResource = useMemo(() => (
|
|
141
|
+
hasAPIResource(apiResources, 'applications', 'argoproj.io') ||
|
|
142
|
+
hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io') ||
|
|
143
|
+
hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
|
|
144
|
+
), [apiResources])
|
|
145
|
+
|
|
146
|
+
// Counts come from radar's /api/resource-counts. The extracted
|
|
147
|
+
// GitOpsTableView reads only the GitOps keys for mode tabs + empty-state
|
|
148
|
+
// checks.
|
|
142
149
|
const countsQuery = useQuery({
|
|
143
150
|
queryKey: ['gitops-resource-counts', namespacesParam],
|
|
144
151
|
queryFn: async () => {
|
|
@@ -217,6 +224,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
217
224
|
const [coldRetrying, setColdRetrying] = useState(false)
|
|
218
225
|
useEffect(() => { coldRetriesRef.current = 0; setColdRetrying(false) }, [apiResources, namespacesParam])
|
|
219
226
|
useEffect(() => {
|
|
227
|
+
if (!hasGitOpsRowResource || rowsQuery.error) { setColdRetrying(false); return }
|
|
220
228
|
if (apiResourcesLoading || rowsQuery.isFetching) return
|
|
221
229
|
if ((rowsQuery.data?.length ?? 0) > 0) { setColdRetrying(false); return }
|
|
222
230
|
if (coldRetriesRef.current >= 4) { setColdRetrying(false); return }
|
|
@@ -224,7 +232,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
224
232
|
const t = window.setTimeout(() => { coldRetriesRef.current += 1; refetchTable() }, 2000)
|
|
225
233
|
return () => window.clearTimeout(t)
|
|
226
234
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
227
|
-
}, [rowsQuery.data, rowsQuery.isFetching, apiResourcesLoading])
|
|
235
|
+
}, [rowsQuery.data, rowsQuery.isFetching, rowsQuery.error, apiResourcesLoading, hasGitOpsRowResource])
|
|
228
236
|
|
|
229
237
|
const handleRowAction = (row: GitOpsRow, action: GitOpsRowAction) => {
|
|
230
238
|
const { kindName: kind, namespace, name, id } = row
|
|
@@ -271,6 +279,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
271
279
|
loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading || coldRetrying}
|
|
272
280
|
error={(rowsQuery.error as Error | null) ?? null}
|
|
273
281
|
counts={countsQuery.data?.counts ?? {}}
|
|
282
|
+
countsUnavailable={countsQuery.data?.unavailable}
|
|
274
283
|
onRefresh={() => rowsQuery.refetch()}
|
|
275
284
|
onRowClick={(row) => {
|
|
276
285
|
const ns = row.namespace || '_'
|
|
@@ -958,5 +967,3 @@ function TopologyCounts({ tree }: { tree: GitOpsResourceTree }) {
|
|
|
958
967
|
</div>
|
|
959
968
|
)
|
|
960
969
|
}
|
|
961
|
-
|
|
962
|
-
|
|
@@ -27,7 +27,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
27
27
|
|
|
28
28
|
// Repo refresh is gated only by `requireHelmWrite` on the backend
|
|
29
29
|
// (handleUpdateRepository deliberately skips requireCloudRole — it
|
|
30
|
-
// mutates pod-local chart cache, not cluster state). So the
|
|
30
|
+
// mutates pod-local chart cache, not cluster state). So the frontend gate
|
|
31
31
|
// here must NOT include the Cloud role check, or Cloud viewers with
|
|
32
32
|
// rbac.helm=true would be blocked from a refresh the backend allows.
|
|
33
33
|
const canHelmWrite = useCanHelmWrite()
|
|
@@ -22,7 +22,7 @@ interface RoleGatedPanelProps {
|
|
|
22
22
|
*
|
|
23
23
|
* Bypasses for non-Cloud users (OSS, OIDC, etc.) — `canAtLeast` returns
|
|
24
24
|
* true when no Cloud role is present. The backend gate has the same
|
|
25
|
-
* shape, so the
|
|
25
|
+
* shape, so the frontend stays in lockstep.
|
|
26
26
|
*/
|
|
27
27
|
export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps) {
|
|
28
28
|
const { role, canAtLeast } = useCloudRole()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import type { DashboardResponse, DashboardMetrics, DashboardCRDCount
|
|
2
|
+
import type { DashboardResponse, DashboardMetrics, DashboardCRDCount } from '../../api/client'
|
|
3
3
|
import { HealthRing } from './HealthRing'
|
|
4
4
|
import {
|
|
5
5
|
AlertTriangle, CheckCircle, XCircle,
|
|
@@ -23,12 +23,13 @@ interface ClusterHealthCardProps {
|
|
|
23
23
|
metrics: DashboardMetrics | null
|
|
24
24
|
metricsServerAvailable: boolean
|
|
25
25
|
topCRDs?: DashboardCRDCount[] // Loaded lazily, may be undefined
|
|
26
|
-
|
|
26
|
+
issueCount: number
|
|
27
|
+
hasCriticalIssues: boolean
|
|
27
28
|
nodeVersionSkew: DashboardResponse['nodeVersionSkew']
|
|
28
29
|
onNavigateToKind: (kind: string, group?: string) => void
|
|
29
30
|
onNavigateToView: () => void
|
|
30
31
|
onWarningEventsClick?: () => void
|
|
31
|
-
|
|
32
|
+
onIssuesClick?: () => void
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function getMetricsInstallHint(platform: string): string {
|
|
@@ -113,12 +114,13 @@ export function ClusterHealthCard({
|
|
|
113
114
|
metrics,
|
|
114
115
|
metricsServerAvailable,
|
|
115
116
|
topCRDs: _topCRDs,
|
|
116
|
-
|
|
117
|
+
issueCount,
|
|
118
|
+
hasCriticalIssues,
|
|
117
119
|
nodeVersionSkew,
|
|
118
120
|
onNavigateToKind,
|
|
119
121
|
onNavigateToView,
|
|
120
122
|
onWarningEventsClick,
|
|
121
|
-
|
|
123
|
+
onIssuesClick,
|
|
122
124
|
}: ClusterHealthCardProps) {
|
|
123
125
|
void _topCRDs // Reserved for future CRD display
|
|
124
126
|
|
|
@@ -450,14 +452,14 @@ export function ClusterHealthCard({
|
|
|
450
452
|
<span><span className="font-mono">{health.warningEvents}</span> Warning Events</span>
|
|
451
453
|
</button>
|
|
452
454
|
)}
|
|
453
|
-
{
|
|
455
|
+
{issueCount > 0 && (
|
|
454
456
|
<button
|
|
455
|
-
onClick={
|
|
456
|
-
title="View
|
|
457
|
-
className=
|
|
457
|
+
onClick={onIssuesClick}
|
|
458
|
+
title="View grouped live operational issues"
|
|
459
|
+
className={clsx('badge w-fit gap-1.5 hover:opacity-80 transition-opacity', hasCriticalIssues ? 'status-unhealthy' : 'status-degraded')}
|
|
458
460
|
>
|
|
459
461
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
|
460
|
-
<span>
|
|
462
|
+
<span>{pluralize(issueCount, 'Active Issue')}</span>
|
|
461
463
|
</button>
|
|
462
464
|
)}
|
|
463
465
|
</div>
|