@skyhook-io/radar-app 1.3.1 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +111 -58
- package/src/api/client.ts +29 -1
- package/src/components/ConnectionErrorView.tsx +2 -2
- package/src/components/gitops/GitOpsView.tsx +127 -27
- package/src/components/helm/ChartBrowser.tsx +7 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +4 -6
- package/src/components/helm/InstallWizard.tsx +1 -1
- package/src/components/helm/RoleGatedPanel.tsx +2 -2
- package/src/components/home/ClusterHealthCard.tsx +1 -1
- package/src/components/home/GitOpsControllersCard.tsx +14 -12
- package/src/components/home/HomeView.tsx +84 -56
- package/src/components/home/MCPSetupDialog.tsx +20 -86
- package/src/components/home/mcpToolCatalog.ts +276 -0
- package/src/components/issues/IssuesPane.tsx +78 -0
- package/src/components/portforward/PortForwardButton.tsx +1 -1
- package/src/components/portforward/PortForwardManager.tsx +1 -1
- package/src/components/resource/PrometheusCharts.tsx +18 -159
- package/src/components/resources/ImageFilesystemModal.tsx +1 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +5 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -2
- package/src/components/settings/MyPermissionsDialog.tsx +1 -1
- package/src/components/settings/SettingsDialog.tsx +22 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +8 -1311
- package/src/components/ui/Markdown.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +190 -7
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
|
|
6
6
|
import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
|
|
7
7
|
import { HomeView } from './components/home/HomeView'
|
|
8
8
|
import { DebugOverlay } from './components/DebugOverlay'
|
|
9
|
-
import { TopologyGraph, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
|
|
9
|
+
import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
|
|
10
10
|
import { TimelineView } from './components/timeline/TimelineView'
|
|
11
11
|
import { ResourcesView } from './components/resources/ResourcesView'
|
|
12
12
|
import { serializeColumnFilters } from './components/resources/resource-utils'
|
|
@@ -17,6 +17,7 @@ import { HelmView } from './components/helm/HelmView'
|
|
|
17
17
|
import { TrafficView } from './components/traffic/TrafficView'
|
|
18
18
|
import { CostView } from './components/cost/CostView'
|
|
19
19
|
import { AuditView } from './components/audit/AuditView'
|
|
20
|
+
import { IssuesPane } from './components/issues/IssuesPane'
|
|
20
21
|
import { GitOpsView } from './components/gitops/GitOpsView'
|
|
21
22
|
import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer'
|
|
22
23
|
import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager'
|
|
@@ -41,7 +42,7 @@ import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/con
|
|
|
41
42
|
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
|
|
42
43
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
43
44
|
import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
|
|
44
|
-
import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch
|
|
45
|
+
import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch } from 'lucide-react'
|
|
45
46
|
import { useTheme } from './context/ThemeContext'
|
|
46
47
|
import { Tooltip } from './components/ui/Tooltip'
|
|
47
48
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
@@ -91,34 +92,8 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
|
91
92
|
])
|
|
92
93
|
|
|
93
94
|
// Convert API resource name back to topology node ID prefix
|
|
94
|
-
function apiResourceToNodeIdPrefix(apiResource: string): string {
|
|
95
|
-
const prefixMap: Record<string, string> = {
|
|
96
|
-
'pods': 'pod',
|
|
97
|
-
'services': 'service',
|
|
98
|
-
'deployments': 'deployment',
|
|
99
|
-
'daemonsets': 'daemonset',
|
|
100
|
-
'statefulsets': 'statefulset',
|
|
101
|
-
'replicasets': 'replicaset',
|
|
102
|
-
'ingresses': 'ingress',
|
|
103
|
-
'gateways': 'gateway',
|
|
104
|
-
'httproutes': 'httproute',
|
|
105
|
-
'grpcroutes': 'grpcroute',
|
|
106
|
-
'tcproutes': 'tcproute',
|
|
107
|
-
'tlsroutes': 'tlsroute',
|
|
108
|
-
'configmaps': 'configmap',
|
|
109
|
-
'secrets': 'secret',
|
|
110
|
-
'horizontalpodautoscalers': 'horizontalpodautoscaler',
|
|
111
|
-
'jobs': 'job',
|
|
112
|
-
'cronjobs': 'cronjob',
|
|
113
|
-
'persistentvolumeclaims': 'persistentvolumeclaim',
|
|
114
|
-
'namespaces': 'namespace',
|
|
115
|
-
'httpproxies': 'httpproxy', // Contour
|
|
116
|
-
}
|
|
117
|
-
return prefixMap[apiResource] || apiResource.replace(/s$/, '')
|
|
118
|
-
}
|
|
119
|
-
|
|
120
95
|
// Extended MainView type that includes traffic and cost
|
|
121
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops' | 'compare'
|
|
96
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops' | 'compare' | 'issues'
|
|
122
97
|
|
|
123
98
|
// Extract view from URL path
|
|
124
99
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
@@ -134,6 +109,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
134
109
|
if (path === 'audit') return 'audit'
|
|
135
110
|
if (path === 'gitops') return 'gitops'
|
|
136
111
|
if (path === 'compare') return 'compare'
|
|
112
|
+
if (path === 'issues') return 'issues'
|
|
137
113
|
return 'home'
|
|
138
114
|
}
|
|
139
115
|
|
|
@@ -301,6 +277,8 @@ function AppInner() {
|
|
|
301
277
|
// Topology filter state
|
|
302
278
|
const [visibleKinds, setVisibleKinds] = useState<Set<NodeKind>>(() => new Set(DEFAULT_VISIBLE_KINDS))
|
|
303
279
|
const [filterSidebarCollapsed, setFilterSidebarCollapsed] = useState(false)
|
|
280
|
+
// Topology node-search → canvas focus request (nonce lets the same node re-focus)
|
|
281
|
+
const [topologyFocus, setTopologyFocus] = useState<{ id: string; nonce: number } | null>(null)
|
|
304
282
|
// Track CRD kinds that have been auto-added to visibleKinds so we don't override user toggles
|
|
305
283
|
const seededCRDKindsRef = useRef<Set<string>>(new Set())
|
|
306
284
|
|
|
@@ -344,6 +322,14 @@ function AppInner() {
|
|
|
344
322
|
// Suppress the mainView-change clear effect during controlled expand/collapse transitions.
|
|
345
323
|
const suppressViewClearRef = useRef(false)
|
|
346
324
|
|
|
325
|
+
// On a history Pop (back/forward) the URL is authoritative. The URL-write
|
|
326
|
+
// effect, running with not-yet-synced state, would otherwise write the stale
|
|
327
|
+
// state back and revert the Pop — and oscillate with the URL→state read
|
|
328
|
+
// effect (infinite re-render, React #185, blank page). Suppress the writer
|
|
329
|
+
// for the synchronous reconciliation burst after a Pop, then auto-clear (see
|
|
330
|
+
// the arming effect) so later user-driven writes are never affected.
|
|
331
|
+
const skipUrlWriteAfterPopRef = useRef(false)
|
|
332
|
+
|
|
347
333
|
// Close resource drawer when the /resources route no longer matches the
|
|
348
334
|
// selected drawer resource. This covers both in-view kind switches and
|
|
349
335
|
// cross-kind navigations from expanded drawers (for example Node -> View Pods).
|
|
@@ -409,6 +395,27 @@ function AppInner() {
|
|
|
409
395
|
}
|
|
410
396
|
}, [selectedResource])
|
|
411
397
|
|
|
398
|
+
// Navigate from a detector finding (Audit / Issues) to the resources list for
|
|
399
|
+
// its kind, opening the resource. Shared by both queues — the body was
|
|
400
|
+
// duplicated verbatim at each render site. Encodes the opened resource in the
|
|
401
|
+
// URL (?resource=ns/name) — the same deep-link shape the resources view
|
|
402
|
+
// round-trips — so refresh/share keeps the drawer open instead of dropping it.
|
|
403
|
+
const navigateToResourceList = useCallback((resource: SelectedResource) => {
|
|
404
|
+
const pluralKind = kindToPlural(resource.kind)
|
|
405
|
+
setSelectedResource({ ...resource, kind: pluralKind })
|
|
406
|
+
const newParams = new URLSearchParams(searchParams)
|
|
407
|
+
newParams.delete('kind')
|
|
408
|
+
newParams.delete('mode')
|
|
409
|
+
newParams.delete('group')
|
|
410
|
+
newParams.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
|
|
411
|
+
if (resource.group) {
|
|
412
|
+
newParams.set('apiGroup', resource.group)
|
|
413
|
+
} else {
|
|
414
|
+
newParams.delete('apiGroup')
|
|
415
|
+
}
|
|
416
|
+
navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
|
|
417
|
+
}, [searchParams, navigate])
|
|
418
|
+
|
|
412
419
|
// Collapse from expanded WorkloadView back to drawer
|
|
413
420
|
const handleCollapseFromExpanded = useCallback(() => {
|
|
414
421
|
suppressViewClearRef.current = true
|
|
@@ -843,10 +850,35 @@ function AppInner() {
|
|
|
843
850
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- namespaces and setActiveNamespace are intentionally excluded; we only react to server-side changes.
|
|
844
851
|
}, [namespaceScope, namespaceScopeKey])
|
|
845
852
|
|
|
853
|
+
// Arm the skip on every history Pop (location.key changes per nav), then
|
|
854
|
+
// clear it on the next macrotask. The revert/oscillation is a synchronous
|
|
855
|
+
// re-render burst, so a macrotask-deferred clear covers it; clearing
|
|
856
|
+
// afterward means a stale arm can't survive into an unrelated later write
|
|
857
|
+
// (e.g. a Pop that changes none of the write effect's deps would otherwise
|
|
858
|
+
// leave the flag set and silently drop the next user-driven URL write).
|
|
859
|
+
useEffect(() => {
|
|
860
|
+
if (navigationType !== NavigationType.Pop) {
|
|
861
|
+
// Any non-Pop navigation clears the guard. Without this, a Push/Replace
|
|
862
|
+
// that lands before the macrotask fires would run this cleanup (cancelling
|
|
863
|
+
// the timeout) and re-run as a no-op, leaving the flag stuck true and
|
|
864
|
+
// silently suppressing all later URL writes.
|
|
865
|
+
skipUrlWriteAfterPopRef.current = false
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
skipUrlWriteAfterPopRef.current = true
|
|
869
|
+
const id = setTimeout(() => { skipUrlWriteAfterPopRef.current = false }, 0)
|
|
870
|
+
return () => clearTimeout(id)
|
|
871
|
+
}, [location.key, navigationType])
|
|
872
|
+
|
|
846
873
|
// Update URL query params when state changes (path is handled by setMainView)
|
|
847
874
|
// Read from window.location.search (not React Router's searchParams) to preserve
|
|
848
875
|
// params set by child components via window.history.replaceState (e.g., kind from ResourcesView).
|
|
849
876
|
useEffect(() => {
|
|
877
|
+
// Don't write (and revert) the URL while state is still catching up to a
|
|
878
|
+
// Pop — the read effect below owns syncing state from the popped URL. The
|
|
879
|
+
// flag auto-clears on the next macrotask, so this never blocks a later
|
|
880
|
+
// user-driven write.
|
|
881
|
+
if (skipUrlWriteAfterPopRef.current) return
|
|
850
882
|
const currentSearch = window.location.search
|
|
851
883
|
const params = new URLSearchParams(currentSearch)
|
|
852
884
|
|
|
@@ -1045,6 +1077,21 @@ function AppInner() {
|
|
|
1045
1077
|
}
|
|
1046
1078
|
}, [displayedTopology, visibleKinds, namespaces, topologyMode])
|
|
1047
1079
|
|
|
1080
|
+
// The graph node id of the currently open resource, used to highlight it on
|
|
1081
|
+
// the canvas. Looked up from the topology (not reconstructed) because node
|
|
1082
|
+
// ids are `<lowercaseKind>/<ns>/<name>` with special prefixes for CRD
|
|
1083
|
+
// collisions — rebuilding the string can't match those reliably.
|
|
1084
|
+
const selectedNodeId = useMemo(() => {
|
|
1085
|
+
if (!selectedResource) return undefined
|
|
1086
|
+
const ns = selectedResource.namespace || ''
|
|
1087
|
+
const match = topology?.nodes.find(n =>
|
|
1088
|
+
((n.data.namespace as string) || '') === ns &&
|
|
1089
|
+
n.name === selectedResource.name &&
|
|
1090
|
+
(kindToPlural(n.kind) === selectedResource.kind || n.kind === selectedResource.kind)
|
|
1091
|
+
)
|
|
1092
|
+
return match?.id
|
|
1093
|
+
}, [selectedResource, topology])
|
|
1094
|
+
|
|
1048
1095
|
// Filter handlers
|
|
1049
1096
|
const handleToggleKind = useCallback((kind: NodeKind) => {
|
|
1050
1097
|
setVisibleKinds(prev => {
|
|
@@ -1253,18 +1300,6 @@ function AppInner() {
|
|
|
1253
1300
|
</button>
|
|
1254
1301
|
)}
|
|
1255
1302
|
|
|
1256
|
-
{/* My Permissions — what the current user can do in the cluster,
|
|
1257
|
-
computed live by the apiserver via SelfSubjectRulesReview.
|
|
1258
|
-
Available in embedded mode too — Radar Hub users still benefit
|
|
1259
|
-
from "why can't I do X" debugging. */}
|
|
1260
|
-
<button
|
|
1261
|
-
onClick={() => setShowMyPermissions(true)}
|
|
1262
|
-
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1263
|
-
title="My permissions in this cluster"
|
|
1264
|
-
>
|
|
1265
|
-
<ShieldIcon className="w-4 h-4" />
|
|
1266
|
-
</button>
|
|
1267
|
-
|
|
1268
1303
|
{/* User menu (when auth enabled) — hidden in embedded mode;
|
|
1269
1304
|
host app typically provides its own via rightExtras. */}
|
|
1270
1305
|
{!navCustomization.embedded && <UserMenu />}
|
|
@@ -1497,13 +1532,27 @@ function AppInner() {
|
|
|
1497
1532
|
groupingMode={effectiveGroupingMode}
|
|
1498
1533
|
hideGroupHeader={hideGroupHeader}
|
|
1499
1534
|
onNodeClick={handleNodeClick}
|
|
1500
|
-
selectedNodeId={
|
|
1535
|
+
selectedNodeId={selectedNodeId}
|
|
1501
1536
|
paused={topologyPaused}
|
|
1502
1537
|
onTogglePause={handleTogglePause}
|
|
1503
1538
|
onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
|
|
1504
1539
|
namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
|
|
1505
1540
|
onClearNamespace={namespaces.length >= 1 ? () => setActiveNamespace.mutate({ namespaces: [] }) : undefined}
|
|
1506
1541
|
namespacesKey={namespaces.join(',')}
|
|
1542
|
+
focusNodeId={topologyFocus?.id}
|
|
1543
|
+
focusNonce={topologyFocus?.nonce}
|
|
1544
|
+
/>
|
|
1545
|
+
|
|
1546
|
+
{/* Topology node search overlay - top left */}
|
|
1547
|
+
<TopologySearch
|
|
1548
|
+
nodes={filteredTopology?.nodes ?? []}
|
|
1549
|
+
allNodes={topology?.nodes}
|
|
1550
|
+
viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Traffic' : 'Resources'}
|
|
1551
|
+
onNodeSelect={handleNodeClick}
|
|
1552
|
+
onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
|
|
1553
|
+
// Stack below the namespace breadcrumb (shown only for a single
|
|
1554
|
+
// namespace) so the two don't overlap in the top-left corner.
|
|
1555
|
+
triggerClassName={namespaces.length === 1 ? 'top-12 left-3' : 'top-3 left-3'}
|
|
1507
1556
|
/>
|
|
1508
1557
|
|
|
1509
1558
|
{/* Topology controls overlay - top right */}
|
|
@@ -1612,21 +1661,18 @@ function AppInner() {
|
|
|
1612
1661
|
<AuditView
|
|
1613
1662
|
namespaces={namespaces}
|
|
1614
1663
|
onBack={() => setMainView('home')}
|
|
1615
|
-
onNavigateToResource={
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
}
|
|
1628
|
-
navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
|
|
1629
|
-
}}
|
|
1664
|
+
onNavigateToResource={navigateToResourceList}
|
|
1665
|
+
/>
|
|
1666
|
+
)}
|
|
1667
|
+
|
|
1668
|
+
{/* Issues — per-cluster live triage queue (hidden route: not yet in the
|
|
1669
|
+
nav `views` list; reachable at /issues). Same shared <IssuesView> the
|
|
1670
|
+
Hub fleet uses; resource clicks open the standard resource drawer. */}
|
|
1671
|
+
{mainView === 'issues' && (
|
|
1672
|
+
<IssuesPane
|
|
1673
|
+
namespaces={namespaces}
|
|
1674
|
+
onBack={() => setMainView('home')}
|
|
1675
|
+
onNavigateToResource={navigateToResourceList}
|
|
1630
1676
|
/>
|
|
1631
1677
|
)}
|
|
1632
1678
|
|
|
@@ -1749,7 +1795,14 @@ function AppInner() {
|
|
|
1749
1795
|
{diagnosticsOverlay.shouldRender && <DiagnosticsOverlay isOpen={diagnosticsOverlay.isOpen} onClose={() => setShowDiagnostics(false)} />}
|
|
1750
1796
|
|
|
1751
1797
|
{/* Settings dialog */}
|
|
1752
|
-
<SettingsDialog
|
|
1798
|
+
<SettingsDialog
|
|
1799
|
+
open={showSettings}
|
|
1800
|
+
onClose={() => setShowSettings(false)}
|
|
1801
|
+
onShowMyPermissions={() => {
|
|
1802
|
+
setShowSettings(false)
|
|
1803
|
+
setShowMyPermissions(true)
|
|
1804
|
+
}}
|
|
1805
|
+
/>
|
|
1753
1806
|
<MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
|
|
1754
1807
|
|
|
1755
1808
|
{/* Debug overlay - only in dev mode */}
|
package/src/api/client.ts
CHANGED
|
@@ -237,7 +237,7 @@ export interface DashboardCRDCount {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
// Re-export shared types from k8s-ui — single source of truth
|
|
240
|
-
import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check } from '@skyhook-io/k8s-ui'
|
|
240
|
+
import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check, Issue } from '@skyhook-io/k8s-ui'
|
|
241
241
|
export type DashboardAudit = AuditCardData
|
|
242
242
|
export type { AuditFinding, ResourceGroup, CheckMeta, Check }
|
|
243
243
|
|
|
@@ -338,6 +338,34 @@ export function useAudit(namespaces: string[] = []) {
|
|
|
338
338
|
})
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
// Live cluster Issues — the grouped triage queue (radar's /api/issues =
|
|
342
|
+
// internal/issues.Compose+Classify+Group). Single-cluster here; the Hub fleet
|
|
343
|
+
// view fans the same shape across clusters. keepPreviousData semantics via
|
|
344
|
+
// placeholderData so the queue doesn't flash empty on the 30s refresh.
|
|
345
|
+
// total = rows returned (after the cap); total_matched = rows that matched
|
|
346
|
+
// before the cap. total_matched > total means the queue was truncated — surface
|
|
347
|
+
// that honestly rather than presenting a capped list as if it were complete.
|
|
348
|
+
export interface IssuesResponse {
|
|
349
|
+
issues: Issue[]
|
|
350
|
+
total?: number
|
|
351
|
+
total_matched?: number
|
|
352
|
+
// Present only when RBAC visibility is incomplete (absent = full access).
|
|
353
|
+
// state 'degraded' means core workload reads are denied, so an empty list may
|
|
354
|
+
// mean "can't see" rather than "nothing broken" — the UI must say so.
|
|
355
|
+
visibility?: { state?: string; impact?: string }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function useIssues(namespaces: string[] = []) {
|
|
359
|
+
const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
360
|
+
return useQuery<IssuesResponse>({
|
|
361
|
+
queryKey: ['issues', namespaces],
|
|
362
|
+
queryFn: () => fetchJSON(`/issues${params}`),
|
|
363
|
+
staleTime: 30000,
|
|
364
|
+
refetchInterval: 30000,
|
|
365
|
+
placeholderData: (prev) => prev,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
341
369
|
export function useResourceAudit(kind: string, namespace: string, name: string) {
|
|
342
370
|
return useQuery<AuditFinding[]>({
|
|
343
371
|
queryKey: ['audit', 'resource', kind, namespace, name],
|
|
@@ -197,13 +197,13 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
197
197
|
Context: {connection.context ? (
|
|
198
198
|
<ClusterName name={connection.context} />
|
|
199
199
|
) : (
|
|
200
|
-
<span className="
|
|
200
|
+
<span className="inline-code">(none)</span>
|
|
201
201
|
)}
|
|
202
202
|
</p>
|
|
203
203
|
|
|
204
204
|
{connection.clusterName && (
|
|
205
205
|
<p className="text-sm text-theme-text-secondary mb-4">
|
|
206
|
-
Cluster: <span className="
|
|
206
|
+
Cluster: <span className="inline-code">{connection.clusterName}</span>
|
|
207
207
|
</p>
|
|
208
208
|
)}
|
|
209
209
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
formatGitOpsSourceUrl,
|
|
19
19
|
getGitOpsResourceStatus,
|
|
20
20
|
getGitOpsTool,
|
|
21
|
+
isArgoSuspendedByRadar,
|
|
21
22
|
gitOpsInsightChangeKey,
|
|
22
23
|
initNavigationMap,
|
|
23
24
|
kindToPlural,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
type GitOpsResourceTree,
|
|
35
36
|
type GitOpsInsightRef,
|
|
36
37
|
type GitOpsRow,
|
|
38
|
+
type GitOpsRowAction,
|
|
37
39
|
type GitOpsTreeFilters,
|
|
38
40
|
type GitOpsTreeRef,
|
|
39
41
|
type GitOpsTreePreset,
|
|
@@ -102,6 +104,34 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
102
104
|
const namespacesParam = namespaces.join(',')
|
|
103
105
|
const { data: apiResources, isLoading: apiResourcesLoading } = useAPIResources()
|
|
104
106
|
|
|
107
|
+
const argoSync = useArgoSync()
|
|
108
|
+
const argoRefresh = useArgoRefresh()
|
|
109
|
+
const argoTerminate = useArgoTerminate()
|
|
110
|
+
const argoSuspend = useArgoSuspend()
|
|
111
|
+
const argoResume = useArgoResume()
|
|
112
|
+
const fluxReconcile = useFluxReconcile()
|
|
113
|
+
const fluxSyncWithSource = useFluxSyncWithSource()
|
|
114
|
+
const fluxSuspend = useFluxSuspend()
|
|
115
|
+
const fluxResume = useFluxResume()
|
|
116
|
+
|
|
117
|
+
const [syncDialogRow, setSyncDialogRow] = useState<GitOpsRow | null>(null)
|
|
118
|
+
const [pendingActions, setPendingActions] = useState<Map<string, Set<GitOpsRowAction>>>(new Map())
|
|
119
|
+
|
|
120
|
+
// Mark an action as in-flight (or done) for a given row. Cloning the
|
|
121
|
+
// outer Map + inner Set keeps the state immutable so React rerenders
|
|
122
|
+
// and the per-item spinner flips at the right moment.
|
|
123
|
+
function markAction(rowId: string, action: GitOpsRowAction, on: boolean) {
|
|
124
|
+
setPendingActions((prev) => {
|
|
125
|
+
const next = new Map(prev)
|
|
126
|
+
const current = new Set(next.get(rowId) ?? [])
|
|
127
|
+
if (on) current.add(action)
|
|
128
|
+
else current.delete(action)
|
|
129
|
+
if (current.size === 0) next.delete(rowId)
|
|
130
|
+
else next.set(rowId, current)
|
|
131
|
+
return next
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
105
135
|
useEffect(() => {
|
|
106
136
|
initNavigationMap([...(apiResources ?? []), ...GITOPS_KINDS])
|
|
107
137
|
}, [apiResources])
|
|
@@ -157,23 +187,99 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
157
187
|
refetchInterval: 120_000,
|
|
158
188
|
})
|
|
159
189
|
|
|
190
|
+
// Row mutations invalidate granular keys (['resource', …], ['gitops-tree', …])
|
|
191
|
+
// that don't match the table's aggregate gitops-rows-main / counts queries,
|
|
192
|
+
// so refetch those explicitly — otherwise a row keeps showing the pre-action
|
|
193
|
+
// state (e.g. "Suspend" after a successful suspend) until the 120s poll,
|
|
194
|
+
// inviting a duplicate request. Radar serves reads from an informer cache that
|
|
195
|
+
// lags the write by the watch-propagation delay, so refetch once now (covers
|
|
196
|
+
// an already-current cache) and once shortly after to catch the propagated
|
|
197
|
+
// update; refetch() forces a fetch regardless of staleTime.
|
|
198
|
+
const refetchTable = () => {
|
|
199
|
+
rowsQuery.refetch()
|
|
200
|
+
countsQuery.refetch()
|
|
201
|
+
}
|
|
202
|
+
const refetchTableAfterMutation = () => {
|
|
203
|
+
refetchTable()
|
|
204
|
+
window.setTimeout(refetchTable, 1200)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const handleRowAction = (row: GitOpsRow, action: GitOpsRowAction) => {
|
|
208
|
+
const { kindName: kind, namespace, name, id } = row
|
|
209
|
+
const settle = { onSuccess: refetchTableAfterMutation, onSettled: () => markAction(id, action, false) }
|
|
210
|
+
markAction(id, action, true)
|
|
211
|
+
switch (action) {
|
|
212
|
+
case 'sync':
|
|
213
|
+
// Argo Sync is the one action that confirms — same dialog the
|
|
214
|
+
// detail page uses. The mutation fires from onConfirm; clear the
|
|
215
|
+
// in-flight flag here since the dialog now owns the lifecycle.
|
|
216
|
+
markAction(id, action, false)
|
|
217
|
+
setSyncDialogRow(row)
|
|
218
|
+
return
|
|
219
|
+
case 'refresh':
|
|
220
|
+
argoRefresh.mutate({ namespace, name, hard: false }, settle)
|
|
221
|
+
return
|
|
222
|
+
case 'hard-refresh':
|
|
223
|
+
argoRefresh.mutate({ namespace, name, hard: true }, settle)
|
|
224
|
+
return
|
|
225
|
+
case 'terminate':
|
|
226
|
+
argoTerminate.mutate({ namespace, name }, settle)
|
|
227
|
+
return
|
|
228
|
+
case 'suspend':
|
|
229
|
+
if (row.tool === 'argo') argoSuspend.mutate({ namespace, name }, settle)
|
|
230
|
+
else fluxSuspend.mutate({ kind, namespace, name }, settle)
|
|
231
|
+
return
|
|
232
|
+
case 'resume':
|
|
233
|
+
if (row.tool === 'argo') argoResume.mutate({ namespace, name }, settle)
|
|
234
|
+
else fluxResume.mutate({ kind, namespace, name }, settle)
|
|
235
|
+
return
|
|
236
|
+
case 'reconcile':
|
|
237
|
+
fluxReconcile.mutate({ kind, namespace, name }, settle)
|
|
238
|
+
return
|
|
239
|
+
case 'sync-with-source':
|
|
240
|
+
fluxSyncWithSource.mutate({ kind, namespace, name }, settle)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
160
245
|
return (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
246
|
+
<>
|
|
247
|
+
<SharedGitOpsTableView
|
|
248
|
+
rows={rowsQuery.data ?? []}
|
|
249
|
+
loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading}
|
|
250
|
+
error={(rowsQuery.error as Error | null) ?? null}
|
|
251
|
+
counts={countsQuery.data?.counts ?? {}}
|
|
252
|
+
onRefresh={() => rowsQuery.refetch()}
|
|
253
|
+
onRowClick={(row) => {
|
|
254
|
+
const ns = row.namespace || '_'
|
|
255
|
+
const params = new URLSearchParams()
|
|
256
|
+
params.set('apiGroup', row.group)
|
|
257
|
+
navigate({ pathname: gitOpsDetailPath(row.kindName, ns, row.name), search: params.toString() })
|
|
258
|
+
}}
|
|
259
|
+
onRowAction={handleRowAction}
|
|
260
|
+
pendingRowActions={pendingActions}
|
|
261
|
+
searchHotkey
|
|
262
|
+
globalNamespaces={namespaces}
|
|
263
|
+
onClearNamespaces={onClearNamespaces}
|
|
264
|
+
/>
|
|
265
|
+
<SyncOptionsDialog
|
|
266
|
+
open={!!syncDialogRow}
|
|
267
|
+
appLabel={syncDialogRow ? `${syncDialogRow.namespace}/${syncDialogRow.name}` : ''}
|
|
268
|
+
pending={argoSync.isPending}
|
|
269
|
+
onCancel={() => setSyncDialogRow(null)}
|
|
270
|
+
onConfirm={(opts) => {
|
|
271
|
+
if (!syncDialogRow) return
|
|
272
|
+
const { namespace, name } = syncDialogRow
|
|
273
|
+
argoSync.mutate(
|
|
274
|
+
{ namespace, name, ...opts },
|
|
275
|
+
// onSettled so the dialog closes on both success and error —
|
|
276
|
+
// otherwise the error toast surfaces behind the still-open
|
|
277
|
+
// modal and the user can't read it.
|
|
278
|
+
{ onSuccess: refetchTableAfterMutation, onSettled: () => setSyncDialogRow(null) },
|
|
279
|
+
)
|
|
280
|
+
}}
|
|
281
|
+
/>
|
|
282
|
+
</>
|
|
177
283
|
)
|
|
178
284
|
}
|
|
179
285
|
|
|
@@ -219,15 +325,9 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
219
325
|
// pre-suspend prune/selfHeal state for restoration on resume. When present,
|
|
220
326
|
// the app is in a deliberately-paused state (vs. Manual mode, which is a
|
|
221
327
|
// normal operational choice) and should surface a Suspended chip alongside
|
|
222
|
-
// the other status indicators.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
Boolean(
|
|
226
|
-
resourceQ.data?.metadata?.annotations?.['radarhq.io/suspended-prune'] ||
|
|
227
|
-
resourceQ.data?.metadata?.annotations?.['radarhq.io/suspended-selfheal'] ||
|
|
228
|
-
resourceQ.data?.metadata?.annotations?.['skyhook.io/suspended-prune'] ||
|
|
229
|
-
resourceQ.data?.metadata?.annotations?.['skyhook.io/suspended-selfheal'],
|
|
230
|
-
)
|
|
328
|
+
// the other status indicators. Shared with the fleet table's row normalizer
|
|
329
|
+
// (isArgoSuspendedByRadar) so both surfaces agree on what "suspended" means.
|
|
330
|
+
const argoSuspendedByRadar = kind === 'applications' && isArgoSuspendedByRadar(resourceQ.data)
|
|
231
331
|
const effectiveSuspended = (status?.suspended ?? false) || argoSuspendedByRadar
|
|
232
332
|
// Lifecycle gate: when the resource is pending deletion, mutating
|
|
233
333
|
// actions are futile (the controller is processing finalizers and
|
|
@@ -608,7 +708,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
608
708
|
onCancel={() => setSyncDialogOpen(false)}
|
|
609
709
|
onConfirm={(opts) => {
|
|
610
710
|
argoSync.mutate({ namespace, name, ...opts }, {
|
|
611
|
-
|
|
711
|
+
onSettled: () => setSyncDialogOpen(false),
|
|
612
712
|
})
|
|
613
713
|
}}
|
|
614
714
|
/>
|
|
@@ -627,7 +727,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
627
727
|
return
|
|
628
728
|
}
|
|
629
729
|
argoRollback.mutate({ namespace, name, id, ...opts }, {
|
|
630
|
-
|
|
730
|
+
onSettled: () => setRollbackTarget(null),
|
|
631
731
|
})
|
|
632
732
|
}}
|
|
633
733
|
/>
|
|
@@ -307,7 +307,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
307
307
|
<div className="text-sm mt-1">
|
|
308
308
|
<p>No Helm repositories configured.</p>
|
|
309
309
|
<p className="mt-1">
|
|
310
|
-
Add repositories using <code className="
|
|
310
|
+
Add repositories using <code className="inline-code">helm repo add</code>
|
|
311
311
|
</p>
|
|
312
312
|
<p className="mt-2">
|
|
313
313
|
Or try searching on <button onClick={() => setChartSource('artifacthub')} className="text-accent-text hover:underline">ArtifactHub</button>
|
|
@@ -476,7 +476,9 @@ function LocalChartCard({ chart, onSelect }: LocalChartCardProps) {
|
|
|
476
476
|
)}
|
|
477
477
|
<div className="flex-1 min-w-0">
|
|
478
478
|
<div className="flex items-center gap-2">
|
|
479
|
-
<
|
|
479
|
+
<Tooltip content={chart.name} wrapperClassName="min-w-0 flex-1">
|
|
480
|
+
<h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
|
|
481
|
+
</Tooltip>
|
|
480
482
|
{chart.deprecated && (
|
|
481
483
|
<span className={clsx('px-1 py-0.5 text-[10px] rounded', SEVERITY_BADGE.warning)}>
|
|
482
484
|
deprecated
|
|
@@ -540,7 +542,9 @@ function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) {
|
|
|
540
542
|
|
|
541
543
|
{/* Name and org */}
|
|
542
544
|
<div className="flex-1 min-w-0">
|
|
543
|
-
<
|
|
545
|
+
<Tooltip content={chart.name} wrapperClassName="w-full">
|
|
546
|
+
<h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
|
|
547
|
+
</Tooltip>
|
|
544
548
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-theme-text-tertiary">
|
|
545
549
|
<span className="flex items-center gap-1">
|
|
546
550
|
<Building2 className="w-3 h-3" />
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
|
-
import {
|
|
3
|
+
import { FetchResult, useDockReservedHeight } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
|
|
5
5
|
import { TRANSITION_DRAWER } from '../../utils/animation'
|
|
6
6
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
@@ -60,7 +60,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
60
60
|
const canViewSensitive = canAtLeast('member')
|
|
61
61
|
const helmNamespace = release.storageNamespace || release.namespace
|
|
62
62
|
|
|
63
|
-
const { data: releaseDetail, isLoading, refetch: refetchRelease } = useHelmRelease(
|
|
63
|
+
const { data: releaseDetail, isLoading, error: releaseError, refetch: refetchRelease } = useHelmRelease(
|
|
64
64
|
helmNamespace,
|
|
65
65
|
release.name
|
|
66
66
|
)
|
|
@@ -446,10 +446,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
446
446
|
|
|
447
447
|
{/* Content */}
|
|
448
448
|
<div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'helm-drawer-content' }}>
|
|
449
|
-
{
|
|
450
|
-
<
|
|
451
|
-
) : !releaseDetail ? (
|
|
452
|
-
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Release not found</div>
|
|
449
|
+
{!releaseDetail ? (
|
|
450
|
+
<FetchResult loading={isLoading} error={releaseError} notFoundMessage="Release not found" className="h-32" />
|
|
453
451
|
) : (
|
|
454
452
|
<>
|
|
455
453
|
{activeTab === 'overview' && (
|
|
@@ -893,7 +893,7 @@ function ReviewStep({
|
|
|
893
893
|
<div>
|
|
894
894
|
<p className="text-sm font-medium text-amber-400">Installing from ArtifactHub</p>
|
|
895
895
|
<p className="text-xs text-theme-text-secondary mt-1">
|
|
896
|
-
This chart will be installed from: <code className="
|
|
896
|
+
This chart will be installed from: <code className="inline-code">{artifactHubRepoUrl || repo}</code>
|
|
897
897
|
</p>
|
|
898
898
|
</div>
|
|
899
899
|
</div>
|
|
@@ -38,8 +38,8 @@ export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps)
|
|
|
38
38
|
Your role can't view {feature}
|
|
39
39
|
</div>
|
|
40
40
|
<div className="mt-1 max-w-md text-xs text-theme-text-secondary">
|
|
41
|
-
You're signed in as <span className="
|
|
42
|
-
This view requires <span className="
|
|
41
|
+
You're signed in as <span className="inline-code">{role ?? 'viewer'}</span>.
|
|
42
|
+
This view requires <span className="inline-code">{min}</span> or higher.
|
|
43
43
|
Ask a {min} or owner if you need access.
|
|
44
44
|
</div>
|
|
45
45
|
</div>
|
|
@@ -52,7 +52,7 @@ function MetricsUnavailableHint({ platform, metricsServerAvailable }: { platform
|
|
|
52
52
|
content={
|
|
53
53
|
<div className="space-y-1">
|
|
54
54
|
<div className="font-medium">How to fix</div>
|
|
55
|
-
<div>{isPreInstalled ? hint : <>Install by running:<br /><code className="text-[10px] opacity-80">{hint}</code></>}</div>
|
|
55
|
+
<div>{isPreInstalled ? hint : <>Install by running:<br /><code className="inline-code text-[10px] opacity-80">{hint}</code></>}</div>
|
|
56
56
|
</div>
|
|
57
57
|
}
|
|
58
58
|
position="bottom"
|