@skyhook-io/radar-app 1.3.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.3.5",
3
+ "version": "1.4.0",
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, TopologySearch, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
9
+ import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind, gitOpsRouteForResource } 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'
@@ -416,6 +416,23 @@ function AppInner() {
416
416
  navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
417
417
  }, [searchParams, navigate])
418
418
 
419
+ // From the Issues queue: a GitOps reconciler subject (Argo Application / Flux
420
+ // Kustomization / HelmRelease) routes to its rich detail page (tree + insights
421
+ // + ops), not the generic resource drawer that's a dead-end for it. Member
422
+ // resources (Pods, Services, …) fall through to the standard resource view.
423
+ const navigateFromIssue = useCallback((resource: SelectedResource) => {
424
+ const gitOpsPath = gitOpsRouteForResource({
425
+ apiVersion: resource.group ? `${resource.group}/v1` : 'v1',
426
+ kind: resource.kind,
427
+ metadata: { namespace: resource.namespace ?? '', name: resource.name },
428
+ })
429
+ if (gitOpsPath) {
430
+ navigate(gitOpsPath)
431
+ return
432
+ }
433
+ navigateToResourceList(resource)
434
+ }, [navigate, navigateToResourceList])
435
+
419
436
  // Collapse from expanded WorkloadView back to drawer
420
437
  const handleCollapseFromExpanded = useCallback(() => {
421
438
  suppressViewClearRef.current = true
@@ -1667,12 +1684,13 @@ function AppInner() {
1667
1684
 
1668
1685
  {/* Issues — per-cluster live triage queue (hidden route: not yet in the
1669
1686
  nav `views` list; reachable at /issues). Same shared <IssuesView> the
1670
- Hub fleet uses; resource clicks open the standard resource drawer. */}
1687
+ Hub fleet uses; a GitOps reconciler subject routes to its detail page,
1688
+ other resources open the standard resource view. */}
1671
1689
  {mainView === 'issues' && (
1672
1690
  <IssuesPane
1673
1691
  namespaces={namespaces}
1674
1692
  onBack={() => setMainView('home')}
1675
- onNavigateToResource={navigateToResourceList}
1693
+ onNavigateToResource={navigateFromIssue}
1676
1694
  />
1677
1695
  )}
1678
1696
 
package/src/api/client.ts CHANGED
@@ -35,7 +35,7 @@ import { pluralToKind } from '../utils/navigation'
35
35
  // and handles 401 responses globally. Merges caller-provided headers with
36
36
  // auth headers from the config module so library consumers (Radar Hub) can
37
37
  // inject Authorization bearer tokens without each call site knowing.
38
- function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
38
+ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
39
39
  const headers = new Headers(init?.headers)
40
40
  for (const [k, v] of Object.entries(getAuthHeaders())) {
41
41
  if (!headers.has(k)) headers.set(k, v)
@@ -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, Issue } from '@skyhook-io/k8s-ui'
240
+ import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check, Issue, IssueRecentChange } from '@skyhook-io/k8s-ui'
241
241
  export type DashboardAudit = AuditCardData
242
242
  export type { AuditFinding, ResourceGroup, CheckMeta, Check }
243
243
 
@@ -349,6 +349,8 @@ export interface IssuesResponse {
349
349
  issues: Issue[]
350
350
  total?: number
351
351
  total_matched?: number
352
+ recent_changes?: IssueRecentChange[]
353
+ recent_changes_reason?: string
352
354
  // Present only when RBAC visibility is incomplete (absent = full access).
353
355
  // state 'degraded' means core workload reads are denied, so an empty list may
354
356
  // mean "can't see" rather than "nothing broken" — the UI must say so.
@@ -738,6 +740,10 @@ export interface AuthMe {
738
740
  /** Pre-computed Cloud tier from `cloud:<tier>` group prefix.
739
741
  * Absent when not running under Cloud (OSS, OIDC, no role group). */
740
742
  cloudRole?: CloudRole
743
+ /** Proxy mode only: whether an upstream sign-out URL is configured.
744
+ * When false, logout clears Radar's cookie but the proxy may re-auth
745
+ * the same user on the next request. */
746
+ proxyLogoutConfigured?: boolean
741
747
  }
742
748
 
743
749
  export function useAuthMe() {
@@ -1695,8 +1701,11 @@ export function useDeleteResource() {
1695
1701
  const queryClient = useQueryClient()
1696
1702
 
1697
1703
  return useMutation({
1698
- mutationFn: async ({ kind, namespace, name, force }: { kind: string; namespace: string; name: string; force?: boolean }) => {
1704
+ mutationFn: async ({ kind, group, namespace, name, force }: { kind: string; group?: string; namespace: string; name: string; force?: boolean }) => {
1699
1705
  const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
1706
+ if (group) {
1707
+ url.searchParams.set('group', group)
1708
+ }
1700
1709
  if (force) {
1701
1710
  url.searchParams.set('force', 'true')
1702
1711
  }
@@ -1721,6 +1730,44 @@ export function useDeleteResource() {
1721
1730
  })
1722
1731
  }
1723
1732
 
1733
+ export function useBulkDeleteResources() {
1734
+ const queryClient = useQueryClient()
1735
+
1736
+ return useMutation({
1737
+ mutationFn: async ({ items, force }: { items: Array<{ kind: string; group?: string; namespace: string; name: string }>; force?: boolean }) => {
1738
+ const results = await Promise.allSettled(
1739
+ items.map(async ({ kind, group, namespace, name }) => {
1740
+ const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
1741
+ if (group) url.searchParams.set('group', group)
1742
+ if (force) url.searchParams.set('force', 'true')
1743
+ const response = await apiFetch(url.toString(), { method: 'DELETE' })
1744
+ if (!response.ok) {
1745
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1746
+ throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
1747
+ }
1748
+ return { kind, namespace, name }
1749
+ })
1750
+ )
1751
+ const failed = results.filter(r => r.status === 'rejected')
1752
+ if (failed.length > 0) {
1753
+ throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
1754
+ }
1755
+ return { deleted: items.length }
1756
+ },
1757
+ meta: {
1758
+ errorMessage: 'Failed to delete some resources',
1759
+ successMessage: 'Resources deleted',
1760
+ },
1761
+ // onSettled, not onSuccess — a partial failure still deleted some
1762
+ // resources, and the table must refetch to drop them.
1763
+ onSettled: () => {
1764
+ queryClient.invalidateQueries({ queryKey: ['resources'] })
1765
+ queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
1766
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
1767
+ },
1768
+ })
1769
+ }
1770
+
1724
1771
  // Apply (create or update) a resource from YAML
1725
1772
  export interface ApplyResourceResult {
1726
1773
  name: string
@@ -4,6 +4,7 @@ import type { ConnectionState } from '../context/ConnectionContext'
4
4
  import { ContextSwitcher } from './ContextSwitcher'
5
5
  import { parseContextName } from '../utils/context-name'
6
6
  import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
7
+ import { useAuthMe } from '../api/client'
7
8
 
8
9
  interface ConnectionErrorViewProps {
9
10
  connection: ConnectionState
@@ -165,14 +166,22 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
165
166
  const authInfo = isAuth ? getAuthHints(connection.context || '') : null
166
167
  const errorInfo = authInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
167
168
  const openLocalTerminal = useOpenLocalTerminal()
169
+ const { data: authMe } = useAuthMe()
168
170
 
169
- // Build a command that auto-retries connection after successful auth
171
+ // Auto-retry after successful auth. The terminal shell runs on the server
172
+ // host, so the auth command itself fixes the server's credentials in every
173
+ // mode — but the chained retry curl carries no session cookie, so it 401s
174
+ // once /api/connection is auth-gated. Only chain it when auth is *known*
175
+ // disabled (authMe still loading → don't chain a doomed call).
170
176
  const retryCmd = `curl -s -X POST http://${window.location.host}/api/connection/retry > /dev/null`
171
177
 
172
178
  const handleAuthInTerminal = () => {
173
179
  if (!authInfo?.authCommand) return
180
+ const cmd = authMe?.authEnabled === false
181
+ ? `${authInfo.authCommand.command} && ${retryCmd}`
182
+ : authInfo.authCommand.command
174
183
  openLocalTerminal({
175
- initialCommand: `${authInfo.authCommand.command} && ${retryCmd}`,
184
+ initialCommand: cmd,
176
185
  title: 'Auth',
177
186
  })
178
187
  }
@@ -67,18 +67,17 @@ export function UserMenu() {
67
67
  </p>
68
68
  )}
69
69
  </div>
70
- {authMe.authMode === 'proxy' ? (
71
- <p className="px-3 py-1.5 text-[11px] text-theme-text-tertiary">
72
- Session managed by auth proxy
70
+ <button
71
+ onClick={handleLogout}
72
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover transition-colors"
73
+ >
74
+ <LogOut className="w-3.5 h-3.5" />
75
+ Logout
76
+ </button>
77
+ {authMe.authMode === 'proxy' && !authMe.proxyLogoutConfigured && (
78
+ <p className="px-3 py-1.5 text-[11px] text-theme-text-tertiary border-t border-theme-border">
79
+ Logout clears the Radar session. The auth proxy may sign you back in automatically.
73
80
  </p>
74
- ) : (
75
- <button
76
- onClick={handleLogout}
77
- className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover transition-colors"
78
- >
79
- <LogOut className="w-3.5 h-3.5" />
80
- Logout
81
- </button>
82
81
  )}
83
82
  </div>
84
83
  )}
@@ -52,7 +52,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
52
52
  },
53
53
  {
54
54
  name: 'get_resource',
55
- desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics sidecars.',
55
+ desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics data.',
56
56
  params: [
57
57
  { arg: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service' },
58
58
  { arg: 'name', required: true, desc: 'resource name' },
@@ -109,10 +109,10 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
109
109
  },
110
110
  {
111
111
  name: 'diagnose',
112
- desc: 'One-call workload root-cause bundle: spec + resourceContext + current AND previous logs across all pods + warning events + startup-blocker analysis. For Pod/Deployment/StatefulSet/DaemonSet.',
112
+ desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers get status summary + parsed related issues.',
113
113
  params: [
114
- { arg: 'kind', required: true, desc: 'pod, deployment, statefulset, or daemonset' },
115
- { arg: 'namespace', required: true, desc: 'workload namespace' },
114
+ { arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, or helmrelease' },
115
+ { arg: 'namespace', required: true, desc: 'resource namespace' },
116
116
  { arg: 'name', required: true, desc: 'resource name' },
117
117
  { arg: 'container', desc: 'specific container (defaults to all)' },
118
118
  { arg: 'tail_lines', desc: 'lines per pod/stream (default 100)' },
@@ -163,10 +163,10 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
163
163
  },
164
164
  {
165
165
  name: 'list_packages',
166
- desc: 'Unified "what\'s installed" view across Helm releases, workload labels, CRD registrations, and GitOps declarations (Argo + Flux), with sources, versions, and health.',
166
+ desc: 'Unified "what\'s installed" view across Helm releases, workload labels, CRD registrations, and GitOps declarations (Argo + Flux), with sources, versions, health, and a sourceLegend for the stable source codes.',
167
167
  params: [
168
168
  { arg: 'namespace', desc: 'filter to a specific namespace' },
169
- { arg: 'source', desc: 'H (Helm), L (labels), C (CRDs), A (Argo), F (Flux)' },
169
+ { arg: 'source', desc: 'H/helm, L/labels, C/crds, A/argocd, F/fluxcd' },
170
170
  { arg: 'chart', desc: 'case-insensitive chart-name substring' },
171
171
  ],
172
172
  },
@@ -10,9 +10,11 @@ interface LogsViewerProps {
10
10
  podName: string
11
11
  containers: string[]
12
12
  initialContainer?: string
13
+ /** Start streaming on mount. Default true — callers pass false for terminal (completed/failed) pods. */
14
+ autoStream?: boolean
13
15
  }
14
16
 
15
- export function LogsViewer({ namespace, podName, containers, initialContainer }: LogsViewerProps) {
17
+ export function LogsViewer({ namespace, podName, containers, initialContainer, autoStream = true }: LogsViewerProps) {
16
18
  const desktopDownload = useDesktopDownload()
17
19
  const { theme } = useTheme()
18
20
 
@@ -42,6 +44,7 @@ export function LogsViewer({ namespace, podName, containers, initialContainer }:
42
44
  createStream={makeStream}
43
45
  overrideDownload={desktopDownload}
44
46
  forceDark={theme === 'dark' ? true : undefined}
47
+ autoStream={autoStream}
45
48
  />
46
49
  )
47
50
  }
@@ -9,9 +9,11 @@ interface WorkloadLogsViewerProps {
9
9
  kind: string
10
10
  namespace: string
11
11
  name: string
12
+ /** Start streaming on mount. Default true — workload logs are aggregated from live pods. */
13
+ autoStream?: boolean
12
14
  }
13
15
 
14
- export function WorkloadLogsViewer({ kind, namespace, name }: WorkloadLogsViewerProps) {
16
+ export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true }: WorkloadLogsViewerProps) {
15
17
  const desktopDownload = useDesktopDownload()
16
18
  const { theme } = useTheme()
17
19
 
@@ -38,6 +40,7 @@ export function WorkloadLogsViewer({ kind, namespace, name }: WorkloadLogsViewer
38
40
  createStream={makeStream}
39
41
  overrideDownload={desktopDownload}
40
42
  forceDark={theme === 'dark' ? true : undefined}
43
+ autoStream={autoStream}
41
44
  />
42
45
  )
43
46
  }
@@ -1,7 +1,7 @@
1
1
  import { useState, useMemo, useCallback, useEffect } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
- import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
4
+ import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources } from '../../api/client'
5
5
  import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
6
6
  import { useAPIResources } from '../../api/apiResources'
7
7
  import { initNavigationMap } from '@skyhook-io/k8s-ui'
@@ -146,6 +146,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
146
146
  const openLogs = useOpenLogs()
147
147
  const openWorkloadLogs = useOpenWorkloadLogs()
148
148
 
149
+ // Bulk delete
150
+ const bulkDeleteMutation = useBulkDeleteResources()
151
+
149
152
  // Navigation adapter. k8s-ui constructs paths from `basePath` (which
150
153
  // includes the router basename so they line up with window.location.pathname
151
154
  // for path-equality checks) and from `window.location.pathname` directly.
@@ -224,6 +227,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
224
227
  onOpenWorkloadLogs={openWorkloadLogs}
225
228
  // Create resource
226
229
  onCreateResource={handleCreateResource}
230
+ // Bulk operations
231
+ onBulkDelete={(items, options) => bulkDeleteMutation.mutate({ items, force: options?.force }, { onSuccess: options?.onSuccess })}
232
+ isBulkDeleting={bulkDeleteMutation.isPending}
227
233
  />
228
234
  <CreateResourceDialog
229
235
  open={createDialogOpen}
@@ -705,6 +705,12 @@ function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInit
705
705
  return names
706
706
  }, [resource])
707
707
 
708
+ // A terminated pod has nothing to follow — only stream live ones. Wait for
709
+ // the phase to be known so a completed pod isn't briefly streamed while the
710
+ // resource is still loading.
711
+ const phase = resource?.status?.phase
712
+ const autoStream = !!phase && phase !== 'Succeeded' && phase !== 'Failed'
713
+
708
714
  useEffect(() => {
709
715
  if (initialContainer && containers.includes(initialContainer)) {
710
716
  onConsumeInitialContainer?.()
@@ -718,6 +724,7 @@ function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInit
718
724
  podName={name}
719
725
  containers={containers}
720
726
  initialContainer={initialContainer || undefined}
727
+ autoStream={autoStream}
721
728
  />
722
729
  </div>
723
730
  )
@@ -742,6 +749,13 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
742
749
  const { data: logsData } = usePodLogs(podNamespace, selectedPod || '', { tailLines: 1 })
743
750
  const containers = logsData?.containers || []
744
751
 
752
+ // A terminated pod (common for Job/CronJob children) has nothing to follow —
753
+ // only stream live ones. Wait for the pod to load before deciding so we don't
754
+ // briefly auto-stream a completed pod while its phase is still unknown.
755
+ const { data: selectedPodResource } = useResource<any>('Pod', podNamespace, selectedPod || '')
756
+ const phase = selectedPodResource?.status?.phase
757
+ const autoStream = !!phase && phase !== 'Succeeded' && phase !== 'Failed'
758
+
745
759
  if (pods.length === 0) {
746
760
  return (
747
761
  <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
@@ -779,6 +793,7 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
779
793
  podName={selectedPod}
780
794
  containers={containers}
781
795
  initialContainer={initialContainer || undefined}
796
+ autoStream={autoStream}
782
797
  />
783
798
  </div>
784
799
  )}
@@ -1,7 +1,8 @@
1
1
  import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react'
2
2
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
3
  import type { ContextInfo } from '../types'
4
- import { getApiBase, getAuthHeaders, getCredentialsMode } from '../api/config'
4
+ import { getApiBase } from '../api/config'
5
+ import { apiFetch } from '../api/client'
5
6
 
6
7
  export type ConnectionStateType = 'connected' | 'disconnected' | 'connecting'
7
8
 
@@ -29,10 +30,11 @@ interface ConnectionContextValue {
29
30
  const ConnectionContext = createContext<ConnectionContextValue | null>(null)
30
31
 
31
32
  async function fetchConnectionStatus(): Promise<ConnectionStatusResponse> {
32
- const response = await fetch(`${getApiBase()}/connection`, {
33
- credentials: getCredentialsMode(),
34
- headers: getAuthHeaders(),
35
- })
33
+ // apiFetch handles a 401 globally (re-auth redirect). These endpoints are
34
+ // no longer auth-exempt, so a session that expires while the connection-
35
+ // error screen is parked open must route through that path rather than
36
+ // surfacing as a misleading "cannot connect to cluster" error.
37
+ const response = await apiFetch(`${getApiBase()}/connection`)
36
38
  if (!response.ok) {
37
39
  throw new Error('Failed to fetch connection status')
38
40
  }
@@ -40,10 +42,8 @@ async function fetchConnectionStatus(): Promise<ConnectionStatusResponse> {
40
42
  }
41
43
 
42
44
  async function retryConnection(): Promise<ConnectionState> {
43
- const response = await fetch(`${getApiBase()}/connection/retry`, {
45
+ const response = await apiFetch(`${getApiBase()}/connection/retry`, {
44
46
  method: 'POST',
45
- credentials: getCredentialsMode(),
46
- headers: getAuthHeaders(),
47
47
  })
48
48
  if (!response.ok) {
49
49
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))