@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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +111 -58
  3. package/src/api/client.ts +29 -1
  4. package/src/components/ConnectionErrorView.tsx +2 -2
  5. package/src/components/gitops/GitOpsView.tsx +127 -27
  6. package/src/components/helm/ChartBrowser.tsx +7 -3
  7. package/src/components/helm/HelmReleaseDrawer.tsx +4 -6
  8. package/src/components/helm/InstallWizard.tsx +1 -1
  9. package/src/components/helm/RoleGatedPanel.tsx +2 -2
  10. package/src/components/home/ClusterHealthCard.tsx +1 -1
  11. package/src/components/home/GitOpsControllersCard.tsx +14 -12
  12. package/src/components/home/HomeView.tsx +84 -56
  13. package/src/components/home/MCPSetupDialog.tsx +20 -86
  14. package/src/components/home/mcpToolCatalog.ts +276 -0
  15. package/src/components/issues/IssuesPane.tsx +78 -0
  16. package/src/components/portforward/PortForwardButton.tsx +1 -1
  17. package/src/components/portforward/PortForwardManager.tsx +1 -1
  18. package/src/components/resource/PrometheusCharts.tsx +18 -159
  19. package/src/components/resources/ImageFilesystemModal.tsx +1 -2
  20. package/src/components/resources/renderers/RoleBindingRenderer.tsx +5 -3
  21. package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -2
  22. package/src/components/settings/MyPermissionsDialog.tsx +1 -1
  23. package/src/components/settings/SettingsDialog.tsx +22 -2
  24. package/src/components/timeline/TimelineSwimlanes.tsx +8 -1311
  25. package/src/components/ui/Markdown.tsx +1 -1
  26. package/src/components/ui/UpdateNotification.tsx +1 -1
  27. package/src/components/workload/WorkloadView.tsx +190 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
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",
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, Shield as ShieldIcon } from 'lucide-react'
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={selectedResource ? `${apiResourceToNodeIdPrefix(selectedResource.kind)}-${selectedResource.namespace}-${selectedResource.name}` : undefined}
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={(resource) => {
1616
- const pluralKind = kindToPlural(resource.kind)
1617
- setSelectedResource({ ...resource, kind: pluralKind })
1618
- const newParams = new URLSearchParams(searchParams)
1619
- newParams.delete('kind')
1620
- newParams.delete('mode')
1621
- newParams.delete('group')
1622
- newParams.delete('resource')
1623
- if (resource.group) {
1624
- newParams.set('apiGroup', resource.group)
1625
- } else {
1626
- newParams.delete('apiGroup')
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 open={showSettings} onClose={() => setShowSettings(false)} />
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="font-mono text-theme-text-primary">(none)</span>
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="font-mono text-theme-text-primary">{connection.clusterName}</span>
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
- <SharedGitOpsTableView
162
- rows={rowsQuery.data ?? []}
163
- loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading}
164
- error={(rowsQuery.error as Error | null) ?? null}
165
- counts={countsQuery.data?.counts ?? {}}
166
- onRefresh={() => rowsQuery.refetch()}
167
- onRowClick={(row) => {
168
- const ns = row.namespace || '_'
169
- const params = new URLSearchParams()
170
- params.set('apiGroup', row.group)
171
- navigate({ pathname: gitOpsDetailPath(row.kindName, ns, row.name), search: params.toString() })
172
- }}
173
- searchHotkey
174
- globalNamespaces={namespaces}
175
- onClearNamespaces={onClearNamespaces}
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
- const argoSuspendedByRadar =
224
- kind === 'applications' &&
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
- onSuccess: () => setSyncDialogOpen(false),
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
- onSuccess: () => setRollbackTarget(null),
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="bg-theme-elevated px-1 rounded">helm repo add</code>
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
- <h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
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
- <h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
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 { PaneLoader, useDockReservedHeight } from '@skyhook-io/k8s-ui'
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
- {isLoading ? (
450
- <PaneLoader className="h-32" />
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="bg-theme-elevated px-1 rounded">{artifactHubRepoUrl || repo}</code>
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="font-mono text-theme-text-primary">{role ?? 'viewer'}</span>.
42
- This view requires <span className="font-mono text-theme-text-primary">{min}</span> or higher.
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"