@skyhook-io/radar-app 1.0.1 → 1.1.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/src/api/client.ts CHANGED
@@ -23,6 +23,8 @@ import type {
23
23
  InstallChartRequest,
24
24
  ArtifactHubSearchResult,
25
25
  ArtifactHubChartDetail,
26
+ GitOpsResourceTree,
27
+ GitOpsInsight,
26
28
  } from '../types'
27
29
  import type { GitOpsOperationResponse } from '../types/gitops'
28
30
  import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
@@ -256,6 +258,31 @@ export interface DashboardNetworkPolicyCoverage {
256
258
  totalWorkloads: number
257
259
  }
258
260
 
261
+ export interface DashboardGitOpsControllers {
262
+ // Aggregate roll-up across all detected controllers.
263
+ status: 'healthy' | 'degraded' | 'crashing'
264
+ controllers: DashboardGitOpsController[]
265
+ }
266
+
267
+ export interface DashboardGitOpsController {
268
+ name: string
269
+ // Tool vocabulary aligns with the backend `ctrlTool*` constants in
270
+ // internal/server/dashboard_gitops.go and the GitOps tree-builder
271
+ // tags in pkg/gitops/tree/graph.go (`gitopsTool`). Keep these three
272
+ // surfaces in sync — diverging vocabulary across surfaces was a real
273
+ // source of confusion until consolidated.
274
+ tool: 'argocd' | 'fluxcd'
275
+ namespace: string
276
+ ready: number
277
+ total: number
278
+ // Per-controller status (aggregate is in the parent and excludes
279
+ // 'pending' — it normalizes into 'degraded' there).
280
+ status: 'healthy' | 'degraded' | 'crashing' | 'pending'
281
+ // Reason for the crash when status === 'crashing'. Common values:
282
+ // "CrashLoopBackOff", "Error". Empty for non-crashing states.
283
+ crashReason?: string
284
+ }
285
+
259
286
  export interface DashboardResponse {
260
287
  cluster: DashboardCluster
261
288
  health: DashboardHealth
@@ -270,6 +297,7 @@ export interface DashboardResponse {
270
297
  certificateHealth: DashboardCertificateHealth | null
271
298
  networkPolicyCoverage: DashboardNetworkPolicyCoverage | null
272
299
  audit: DashboardAudit | null
300
+ gitopsControllers: DashboardGitOpsControllers | null
273
301
  nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
274
302
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
275
303
  partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
@@ -783,6 +811,46 @@ export function useTopology(namespaces: string[], viewMode: string = 'resources'
783
811
  })
784
812
  }
785
813
 
814
+ export function useGitOpsTree(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
815
+ const ns = namespace || '_'
816
+ const params = new URLSearchParams()
817
+ if (group) params.set('group', group)
818
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
819
+ const queryString = params.toString()
820
+
821
+ return useQuery<GitOpsResourceTree>({
822
+ queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
823
+ queryFn: () => fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
824
+ enabled: Boolean(kind && name),
825
+ staleTime: 5000,
826
+ })
827
+ }
828
+
829
+ // Poll fast (2s) while a sync/rollback is in flight so the user sees the
830
+ // outcome quickly; otherwise rely on staleTime + manual refetch. Argo flips
831
+ // operationState.phase from "Running" -> Succeeded/Failed when done, so this
832
+ // auto-quiesces on completion.
833
+ const INSIGHTS_RUNNING_POLL_MS = 2000
834
+
835
+ export function useGitOpsInsights(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
836
+ const ns = namespace || '_'
837
+ const params = new URLSearchParams()
838
+ if (group) params.set('group', group)
839
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
840
+ const queryString = params.toString()
841
+
842
+ return useQuery<GitOpsInsight>({
843
+ queryKey: ['gitops-insights', kind, namespace, name, group, namespaces],
844
+ queryFn: () => fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
845
+ enabled: Boolean(kind && name),
846
+ staleTime: 5000,
847
+ refetchInterval: (query) => {
848
+ const phase = query.state.data?.summary?.operationPhase
849
+ return phase === 'Running' ? INSIGHTS_RUNNING_POLL_MS : false
850
+ },
851
+ })
852
+ }
853
+
786
854
  // Generic resource fetching - returns resource with relationships
787
855
  // Uses '_' as placeholder for cluster-scoped resources (empty namespace)
788
856
  export function useResource<T>(kind: string, namespace: string, name: string, group?: string) {
@@ -2232,6 +2300,7 @@ export function useArtifactHubChart(repoName: string, chartName: string, version
2232
2300
 
2233
2301
  interface GitOpsMutationConfig<TVariables> {
2234
2302
  getPath: (variables: TVariables) => string
2303
+ getBody?: (variables: TVariables) => unknown
2235
2304
  errorMessage: string
2236
2305
  successMessage: string
2237
2306
  getInvalidateKeys: (variables: TVariables) => (string | undefined)[][]
@@ -2248,6 +2317,8 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
2248
2317
  mutationFn: async (variables: TVariables): Promise<GitOpsOperationResponse> => {
2249
2318
  const response = await apiFetch(`${getApiBase()}${config.getPath(variables)}`, {
2250
2319
  method: 'POST',
2320
+ headers: config.getBody ? { 'Content-Type': 'application/json' } : undefined,
2321
+ body: config.getBody ? JSON.stringify(config.getBody(variables)) : undefined,
2251
2322
  })
2252
2323
  if (!response.ok) {
2253
2324
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
@@ -2270,16 +2341,46 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
2270
2341
 
2271
2342
  // Common variable types
2272
2343
  type FluxResourceVars = { kind: string; namespace: string; name: string }
2344
+ // ArgoAppVars identifies the target Application. Used by mutations that don't
2345
+ // take a body (terminate, suspend, resume, refresh).
2273
2346
  type ArgoAppVars = { namespace: string; name: string }
2347
+ // ArgoSyncVars extends ArgoAppVars with the sync request body fields. Only
2348
+ // useArgoSync sends these — splitting the type prevents callers from passing
2349
+ // resources/revision/prune to mutations that would silently drop them.
2350
+ type ArgoSyncVars = ArgoAppVars & {
2351
+ resources?: Array<{ group?: string; kind: string; namespace?: string; name: string }>
2352
+ revision?: string
2353
+ prune?: boolean
2354
+ dryRun?: boolean
2355
+ force?: boolean
2356
+ applyOnly?: boolean
2357
+ // Free-form Argo SyncOption strings, e.g. "Replace=true",
2358
+ // "ServerSideApply=true", "PruneLast=true". Caller is responsible for
2359
+ // spelling.
2360
+ syncOptions?: string[]
2361
+ }
2362
+
2363
+ // ArgoRollbackVars targets a specific Argo history entry by ID. Prune and
2364
+ // DryRun mirror the sync flags so the rollback dialog can offer the same
2365
+ // safety net.
2366
+ type ArgoRollbackVars = ArgoAppVars & {
2367
+ id: number
2368
+ prune?: boolean
2369
+ dryRun?: boolean
2370
+ }
2274
2371
 
2275
2372
  // Standard invalidation patterns
2276
2373
  const fluxInvalidateKeys = (v: FluxResourceVars) => [
2277
2374
  ['resources', v.kind],
2278
2375
  ['resource', v.kind, v.namespace, v.name],
2376
+ ['gitops-tree', v.kind, v.namespace, v.name],
2377
+ ['gitops-insights', v.kind, v.namespace, v.name],
2279
2378
  ]
2280
2379
  const argoInvalidateKeys = (v: ArgoAppVars) => [
2281
2380
  ['resources', 'applications'],
2282
2381
  ['resource', 'applications', v.namespace, v.name],
2382
+ ['gitops-tree', 'applications', v.namespace, v.name],
2383
+ ['gitops-insights', 'applications', v.namespace, v.name],
2283
2384
  ]
2284
2385
 
2285
2386
  // ============================================================================
@@ -2324,13 +2425,30 @@ export const useFluxSyncWithSource = createGitOpsMutation<FluxResourceVars>({
2324
2425
  // ArgoCD API hooks
2325
2426
  // ============================================================================
2326
2427
 
2327
- export const useArgoSync = createGitOpsMutation<ArgoAppVars>({
2428
+ export const useArgoSync = createGitOpsMutation<ArgoSyncVars>({
2328
2429
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/sync`,
2430
+ getBody: (v) => ({
2431
+ resources: v.resources,
2432
+ revision: v.revision,
2433
+ prune: v.prune,
2434
+ dryRun: v.dryRun,
2435
+ force: v.force,
2436
+ applyOnly: v.applyOnly,
2437
+ syncOptions: v.syncOptions,
2438
+ }),
2329
2439
  errorMessage: 'Failed to trigger sync',
2330
2440
  successMessage: 'Sync initiated',
2331
2441
  getInvalidateKeys: argoInvalidateKeys,
2332
2442
  })
2333
2443
 
2444
+ export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
2445
+ getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/rollback`,
2446
+ getBody: (v) => ({ id: v.id, prune: v.prune, dryRun: v.dryRun }),
2447
+ errorMessage: 'Failed to roll back application',
2448
+ successMessage: 'Rollback initiated',
2449
+ getInvalidateKeys: argoInvalidateKeys,
2450
+ })
2451
+
2334
2452
  export const useArgoTerminate = createGitOpsMutation<ArgoAppVars>({
2335
2453
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/terminate`,
2336
2454
  errorMessage: 'Failed to terminate sync',
@@ -2373,8 +2491,13 @@ export function useArgoRefresh() {
2373
2491
  successMessage: 'Application refreshed',
2374
2492
  },
2375
2493
  onSuccess: (_, variables) => {
2376
- queryClient.invalidateQueries({ queryKey: ['resources', 'applications'] })
2377
- queryClient.invalidateQueries({ queryKey: ['resource', 'applications', variables.namespace, variables.name] })
2494
+ // Match the standard Argo invalidation set so the GitOps detail page
2495
+ // (insights strip, resource tree) refetches after Refresh / Hard
2496
+ // Refresh — without these two extra keys the user clicks Refresh and
2497
+ // sees stale insight/tree data until the next staleTime tick.
2498
+ argoInvalidateKeys(variables).forEach((key) =>
2499
+ queryClient.invalidateQueries({ queryKey: key })
2500
+ )
2378
2501
  },
2379
2502
  })
2380
2503
  }
@@ -2481,6 +2604,17 @@ export function useNamespaceScope() {
2481
2604
 
2482
2605
  const NAMESPACE_SWITCH_TIMEOUT = 5000
2483
2606
 
2607
+ export function debugNamespaceLog(label: string, payload?: Record<string, unknown>) {
2608
+ if (typeof window === 'undefined') return
2609
+ const enabled = window.localStorage.getItem('radar:debug:namespaces')
2610
+ if (enabled !== '1' && enabled !== 'true') return
2611
+ console.log(`[namespace-debug] ${label}`, {
2612
+ t: Math.round(performance.now()),
2613
+ href: window.location.href,
2614
+ ...payload,
2615
+ })
2616
+ }
2617
+
2484
2618
  export function useSetActiveNamespace() {
2485
2619
  const queryClient = useQueryClient()
2486
2620
  return useMutation<NamespaceScope, Error, { namespaces: string[] }>({
@@ -2493,8 +2627,10 @@ export function useSetActiveNamespace() {
2493
2627
  errorMessage: 'Failed to update namespace selection',
2494
2628
  },
2495
2629
  mutationFn: async ({ namespaces }) => {
2630
+ debugNamespaceLog('mutation:start', { namespaces })
2496
2631
  const controller = new AbortController()
2497
2632
  const timeoutId = setTimeout(() => controller.abort(), NAMESPACE_SWITCH_TIMEOUT)
2633
+ const startedAt = performance.now()
2498
2634
  try {
2499
2635
  const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
2500
2636
  method: 'POST',
@@ -2503,6 +2639,11 @@ export function useSetActiveNamespace() {
2503
2639
  signal: controller.signal,
2504
2640
  })
2505
2641
  clearTimeout(timeoutId)
2642
+ debugNamespaceLog('mutation:response', {
2643
+ namespaces,
2644
+ status: response.status,
2645
+ durationMs: Math.round(performance.now() - startedAt),
2646
+ })
2506
2647
  if (!response.ok) {
2507
2648
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2508
2649
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2510,23 +2651,32 @@ export function useSetActiveNamespace() {
2510
2651
  return response.json()
2511
2652
  } catch (error) {
2512
2653
  clearTimeout(timeoutId)
2654
+ debugNamespaceLog('mutation:error', {
2655
+ namespaces,
2656
+ durationMs: Math.round(performance.now() - startedAt),
2657
+ error: error instanceof Error ? error.message : String(error),
2658
+ })
2513
2659
  if (error instanceof Error && error.name === 'AbortError') {
2514
2660
  throw new Error('Namespace switch timed out. The cluster may be unreachable.')
2515
2661
  }
2516
2662
  throw error
2517
2663
  }
2518
2664
  },
2519
- onSuccess: () => {
2520
- // The user's view filter changed; every cached query result was
2521
- // shaped by the previous filter, so drop and refetch.
2522
- queryClient.removeQueries()
2523
- queryClient.invalidateQueries()
2665
+ onSuccess: (scope) => {
2666
+ debugNamespaceLog('mutation:success-before-scope-cache-write', {
2667
+ actives: scope.actives,
2668
+ mode: scope.mode,
2669
+ accessibleCount: scope.accessibleNamespaces.length,
2670
+ })
2671
+ queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
2672
+ debugNamespaceLog('mutation:success-after-scope-cache-write')
2524
2673
  },
2525
2674
  onError: () => {
2526
2675
  // A failed switch can leave the server's stored pick out of sync
2527
2676
  // with the cached scope (network timeout after the server wrote;
2528
2677
  // partial mutation). Refetch so the displayed picker matches what
2529
2678
  // the server actually persisted instead of what we assumed.
2679
+ debugNamespaceLog('mutation:on-error-invalidate-scope')
2530
2680
  queryClient.invalidateQueries({ queryKey: ['namespace-scope'] })
2531
2681
  },
2532
2682
  })
@@ -2,6 +2,7 @@
2
2
  export {
3
3
  DockProvider,
4
4
  useDock,
5
+ useDockReservedHeight,
5
6
  useOpenTerminal,
6
7
  useOpenLogs,
7
8
  useOpenWorkloadLogs,
@@ -1,2 +1,2 @@
1
- export { DockProvider, useDock, useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal, useOpenLocalTerminal } from './DockContext'
1
+ export { DockProvider, useDock, useDockReservedHeight, useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal, useOpenLocalTerminal } from './DockContext'
2
2
  export { BottomDock } from './BottomDock'