@skyhook-io/radar-app 0.1.9 → 0.2.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": "0.1.9",
3
+ "version": "0.2.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
@@ -217,6 +217,23 @@ function AppInner() {
217
217
  // Get mainView from URL path
218
218
  const mainView = getViewFromPath(location.pathname)
219
219
 
220
+ // Workload slug after `/resources/` (defaults to `pods`). Bare `/resources` redirects to `/resources/pods`.
221
+ const normalizedResourcesKindSlug = useMemo(() => {
222
+ const m = location.pathname.match(/^\/resources(?:\/([^/]+))?/)
223
+ const slug = m?.[1] ?? ''
224
+ return slug || 'pods'
225
+ }, [location.pathname])
226
+
227
+ // Canonical URL — `/resources` is not stable for bookmarks/sharing; normalize to `/resources/pods`.
228
+ useEffect(() => {
229
+ const path = location.pathname.replace(/\/+$/, '') || '/'
230
+ if (path !== '/resources') return
231
+ navigate(
232
+ { pathname: '/resources/pods', search: location.search, hash: location.hash },
233
+ { replace: true },
234
+ )
235
+ }, [location.pathname, location.search, location.hash, navigate])
236
+
220
237
  // Set mainView by navigating to the path
221
238
  const setMainView = useCallback((view: ExtendedMainView, params?: Record<string, string>) => {
222
239
  const path = view === 'home' ? '/' : `/${view}`
@@ -292,6 +309,23 @@ function AppInner() {
292
309
  // Suppress the mainView-change clear effect during controlled expand/collapse transitions.
293
310
  const suppressViewClearRef = useRef(false)
294
311
 
312
+ // Close resource drawer when switching workload kind in URL (/resources/pods → /resources/deployments).
313
+ // Keeps stale Pod drawer from masking the table after sidebar navigation (Radar Hub / app.radarhq.io).
314
+ const prevResourcesKindSlugRef = useRef<string | null>(null)
315
+ useEffect(() => {
316
+ if (mainView !== 'resources') {
317
+ prevResourcesKindSlugRef.current = null
318
+ return
319
+ }
320
+ const slug = normalizedResourcesKindSlug
321
+ const prev = prevResourcesKindSlugRef.current
322
+ prevResourcesKindSlugRef.current = slug
323
+ if (prev !== null && prev !== slug) {
324
+ setSelectedResource(null)
325
+ setDrawerExpanded(false)
326
+ }
327
+ }, [mainView, normalizedResourcesKindSlug])
328
+
295
329
  // Animation hooks for smooth mount/unmount transitions
296
330
  const resourceDrawer = useAnimatedUnmount(!!selectedResource, 300)
297
331
  const helmDrawer = useAnimatedUnmount(!!(mainView === 'helm' && selectedHelmRelease), 300)
@@ -755,12 +789,7 @@ function AppInner() {
755
789
  <header className="relative z-50 flex items-center justify-between px-4 py-2 bg-theme-base/90 backdrop-blur-sm border-b border-theme-border/50">
756
790
  {/* Left: Logo + Cluster info */}
757
791
  <div className="flex items-center gap-4 shrink-0">
758
- {navCustomization.brandSlot ?? (
759
- <div className="flex items-center gap-2.5">
760
- <Logo />
761
- <span className="text-xl text-theme-text-primary leading-none -translate-y-0.5" style={{ fontFamily: "'DM Sans', sans-serif", fontWeight: 520 }}>radar</span>
762
- </div>
763
- )}
792
+ {navCustomization.brandSlot ?? <Logo />}
764
793
 
765
794
  <div className="flex items-center gap-2">
766
795
  {navCustomization.contextSlot ?? <ContextSwitcher />}
@@ -787,13 +816,17 @@ function AppInner() {
787
816
  }`}
788
817
  />
789
818
  </Tooltip>
790
- <span className="text-xs text-theme-text-tertiary hidden xl:inline">
791
- {!connected
792
- ? 'Disconnected'
793
- : crdDiscoveryStatus === 'discovering'
794
- ? 'Discovering Custom Resources...'
795
- : 'Connected'}
796
- </span>
819
+ {/* Inline label only for non-steady states where the user
820
+ might need to act or wait. The healthy "Connected" case
821
+ is the dot alone; the dot's tooltip discloses it. Keeping
822
+ "Connected" text here would expand the left section and
823
+ collide with the absolute-centered nav block at xl, which
824
+ is the same breakpoint where nav labels appear. */}
825
+ {(!connected || crdDiscoveryStatus === 'discovering') && (
826
+ <span className="text-xs text-theme-text-tertiary hidden xl:inline">
827
+ {!connected ? 'Disconnected' : 'Discovering Custom Resources...'}
828
+ </span>
829
+ )}
797
830
  {!connected && (
798
831
  <button
799
832
  onClick={reconnect}
@@ -834,7 +867,17 @@ function AppInner() {
834
867
  }`}
835
868
  >
836
869
  <Icon className="w-4 h-4" />
837
- <span className="hidden lg:inline">{label}</span>
870
+ {/* Labels appear only when the absolute-centered nav has
871
+ enough horizontal room past the left section. Right-side
872
+ chrome that adds further pressure (Connected text, star
873
+ count) is intentionally pushed to the next tier (xl) so
874
+ label rendering and right-side expansion stay decoupled.
875
+ Per-button Tooltip discloses labels on hover when the
876
+ icon-only viewport is in effect. The 1440 anchor is an
877
+ off-system breakpoint chosen by measurement at the time
878
+ of this PR — recompute if the cluster switcher cap or
879
+ other left-section chrome changes appreciably. */}
880
+ <span className="hidden min-[1440px]:inline">{label}</span>
838
881
  </button>
839
882
  </Tooltip>
840
883
  ))}
@@ -945,7 +988,9 @@ function AppInner() {
945
988
  <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
946
989
  <div className="text-center">
947
990
  <p className="font-medium text-theme-text-primary">Connecting to cluster</p>
948
- <p className="text-sm text-theme-text-secondary mt-1">{connection.context || 'Loading…'}</p>
991
+ {connection.context && (
992
+ <p className="text-sm text-theme-text-secondary mt-1">{connection.context}</p>
993
+ )}
949
994
  {connection.progressMessage && (
950
995
  <p className="text-xs text-theme-text-tertiary animate-pulse mt-3">
951
996
  {connection.progressMessage}
@@ -1379,14 +1424,35 @@ function App() {
1379
1424
  )
1380
1425
  }
1381
1426
 
1382
- // Skyhook logo that switches based on theme
1427
+ // Header brand: emerald-square radar icon + stacked "Radar" / "by Skyhook"
1428
+ // wordmark. Shares its visual shape with the radar-hub-web shell so the
1429
+ // standalone OSS app and the embedded Cloud experience read as the same
1430
+ // product, and is narrow enough to leave room for the cluster switcher
1431
+ // and nav block on standard laptop viewports.
1383
1432
  function Logo() {
1384
- const { theme } = useTheme()
1385
- const logoSrc = theme === 'dark'
1386
- ? '/assets/skyhook/logotype-white-color.svg'
1387
- : '/assets/skyhook/logotype-dark-color.svg'
1388
-
1389
- return <img src={logoSrc} alt="Skyhook" className="h-5 w-auto" />
1433
+ return (
1434
+ <div className="flex items-center gap-2.5">
1435
+ <div className="relative w-7 h-7 rounded-lg overflow-hidden flex-shrink-0 bg-emerald-500/10 border border-emerald-500/20">
1436
+ <img
1437
+ src="/images/radar/radar-icon.svg"
1438
+ alt=""
1439
+ aria-hidden
1440
+ className="w-full h-full p-0.5"
1441
+ // Fail loud on a missing/blocked asset rather than rendering an
1442
+ // empty emerald square next to the wordmark — the latter reads
1443
+ // as broken chrome with no diagnostics. Most likely cause is a
1444
+ // build/deploy path mismatch.
1445
+ onError={(e) =>
1446
+ console.error('Radar logo asset failed to load:', (e.currentTarget as HTMLImageElement).src)
1447
+ }
1448
+ />
1449
+ </div>
1450
+ <div className="flex flex-col leading-none">
1451
+ <span className="font-semibold text-[15px] tracking-tight text-theme-text-primary">Radar</span>
1452
+ <span className="text-[9px] mt-0.5 tracking-wide uppercase text-theme-text-tertiary">by Skyhook</span>
1453
+ </div>
1454
+ </div>
1455
+ )
1390
1456
  }
1391
1457
 
1392
1458
  // GitHub star button with live star count + programmatic starring via gh CLI
package/src/api/client.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  } from '../types'
27
27
  import type { GitOpsOperationResponse } from '../types/gitops'
28
28
  import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
29
+ import { pluralToKind } from '../utils/navigation'
29
30
 
30
31
  // Wrapper around fetch that always includes credentials (for session cookies)
31
32
  // and handles 401 responses globally. Merges caller-provided headers with
@@ -907,27 +908,78 @@ export function useResourceChildren(kind: string, namespace: string, name: strin
907
908
  })
908
909
  }
909
910
 
910
- // Resource-specific events (filtered by resource name)
911
- export function useResourceEvents(kind: string, namespace: string, name: string) {
912
- const params = new URLSearchParams()
913
- params.set('namespace', namespace)
914
- params.set('kind', kind)
915
- params.set('limit', '50')
911
+ export interface ResourceEventsResult {
912
+ k8sEvents: TimelineEvent[]
913
+ updates: TimelineEvent[]
914
+ isLoading: boolean
915
+ // Per-stream errors are surfaced separately so the UI can distinguish
916
+ // "this stream failed" from "no data" — silent fallback to [] would
917
+ // reproduce the exact failure mode #547 is about.
918
+ k8sError: Error | null
919
+ updatesError: Error | null
920
+ }
921
+
922
+ // K8s events and resource updates are fetched separately so a high-frequency
923
+ // informer update stream (e.g. a CrashLoop status field flapping every few
924
+ // seconds) can never starve out user-meaningful K8s events under a shared limit.
925
+ export function useResourceEvents(kind: string, namespace: string, name: string): ResourceEventsResult {
926
+ // The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
927
+ // but callers pass the URL-form kind ("pods").
928
+ const singularKind = pluralToKind(kind)
929
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
930
+
931
+ // Include managed resources — when viewing a specific resource (e.g. a Pod owned
932
+ // by a ReplicaSet, or a K8s Event whose involvedObject is the Pod itself), the
933
+ // default preset's IsManaged() filter would otherwise drop everything.
934
+ const baseParams = () => {
935
+ const p = new URLSearchParams()
936
+ p.set('namespace', namespace)
937
+ p.set('kind', singularKind)
938
+ p.set('include_managed', 'true')
939
+ p.set('since', since)
940
+ return p
941
+ }
916
942
 
917
- // Get events from last 24 hours
918
- const since = new Date(Date.now() - 24 * 60 * 60 * 1000)
919
- params.set('since', since.toISOString())
943
+ const enabled = Boolean(kind && namespace && name)
920
944
 
921
- return useQuery<TimelineEvent[]>({
922
- queryKey: ['resource-events', kind, namespace, name],
945
+ // K8s events: high limit so the full set is always returned. The number of
946
+ // distinct K8s events per resource is naturally bounded — kubelet/controllers
947
+ // dedupe via Reason+InvolvedObject and bump count.
948
+ const k8sQuery = useQuery<TimelineEvent[]>({
949
+ queryKey: ['resource-events', 'k8s', singularKind, namespace, name],
923
950
  queryFn: async () => {
951
+ const params = baseParams()
952
+ params.set('sources', 'k8s_event')
953
+ params.set('limit', '500')
924
954
  const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
925
- // Filter to only events for this specific resource
926
955
  return events.filter(e => e.name === name)
927
956
  },
928
- enabled: Boolean(kind && namespace && name),
929
- refetchInterval: 15000, // Refresh every 15 seconds
957
+ enabled,
958
+ refetchInterval: 15000,
959
+ })
960
+
961
+ // Resource updates (informer diffs + historical): bounded so a flapping
962
+ // resource doesn't return an unbounded payload.
963
+ const updatesQuery = useQuery<TimelineEvent[]>({
964
+ queryKey: ['resource-events', 'updates', singularKind, namespace, name],
965
+ queryFn: async () => {
966
+ const params = baseParams()
967
+ params.set('sources', 'informer,historical')
968
+ params.set('limit', '50')
969
+ const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
970
+ return events.filter(e => e.name === name)
971
+ },
972
+ enabled,
973
+ refetchInterval: 15000,
930
974
  })
975
+
976
+ return {
977
+ k8sEvents: k8sQuery.data ?? [],
978
+ updates: updatesQuery.data ?? [],
979
+ isLoading: k8sQuery.isLoading || updatesQuery.isLoading,
980
+ k8sError: (k8sQuery.error as Error | null) ?? null,
981
+ updatesError: (updatesQuery.error as Error | null) ?? null,
982
+ }
931
983
  }
932
984
 
933
985
  // ============================================================================
@@ -1,5 +1,5 @@
1
1
  import { useMemo, useState } from 'react'
2
- import { Server, AlertTriangle } from 'lucide-react'
2
+ import { AlertTriangle } from 'lucide-react'
3
3
  import {
4
4
  ClusterSwitcher,
5
5
  type ClusterSwitcherItem,
@@ -61,9 +61,14 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
61
61
  : hasMultipleAccounts
62
62
  ? 'Other'
63
63
  : undefined
64
+ // `name` is the raw context — ClusterSwitcher renders it through
65
+ // ClusterName, which collapses GKE/EKS/AKS shapes to the meaningful
66
+ // tail. `secondary` shows the original raw when we collapsed it,
67
+ // so users always see the full context at a glance (rather than
68
+ // having to hover to reveal it).
64
69
  return {
65
70
  id: p.context.name,
66
- name: p.clusterName,
71
+ name: p.context.name,
67
72
  secondary: p.provider ? p.raw : undefined,
68
73
  badge: p.region || undefined,
69
74
  group: { key: groupKey, label: groupLabel },
@@ -148,7 +153,6 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
148
153
  }
149
154
 
150
155
  const currentRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
151
- const currentParsed = parseContextName(currentRaw)
152
156
  const currentId = contexts?.find(c => c.isCurrent)?.name
153
157
 
154
158
  return (
@@ -156,9 +160,7 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
156
160
  <ClusterSwitcher
157
161
  className={className}
158
162
  currentId={currentId}
159
- currentName={currentParsed.clusterName}
160
- currentTooltip={currentRaw}
161
- triggerIcon={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
163
+ currentName={currentRaw}
162
164
  items={items}
163
165
  onSelect={handleSelect}
164
166
  loading={switchContext.isPending}
@@ -160,6 +160,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
160
160
  return (
161
161
  <>
162
162
  <BaseResourcesView
163
+ key={location.pathname}
163
164
  namespaces={namespaces}
164
165
  selectedResource={selectedResource}
165
166
  onResourceClick={onResourceClick}
@@ -17,6 +17,7 @@ import {
17
17
  useArgoSync, useArgoRefresh, useArgoSuspend, useArgoResume,
18
18
  useCordonNode, useUncordonNode, useDrainNode,
19
19
  useCascadeDeletePreview,
20
+ useResourceEvents,
20
21
  fetchJSON,
21
22
  } from '../../api/client'
22
23
  import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
@@ -290,6 +291,16 @@ export function WorkloadView({
290
291
  // Fetch topology for hierarchy building (only when expanded)
291
292
  const { data: topology } = useTopology([namespace], 'resources', { enabled: expanded })
292
293
 
294
+ // Always fetched so Recent Events populates on drawer open; allEvents below is
295
+ // gated on expanded because it's namespace-wide and expensive.
296
+ const {
297
+ k8sEvents: resourceFocusedK8sEvents,
298
+ updates: resourceFocusedUpdates,
299
+ isLoading: resourceFocusedEventsLoading,
300
+ k8sError: resourceFocusedK8sError,
301
+ updatesError: resourceFocusedUpdatesError,
302
+ } = useResourceEvents(kindProp, namespace, name)
303
+
293
304
  // Fetch all events for this resource's namespace (only when expanded)
294
305
  const { data: allEvents, isLoading: eventsLoading } = useChanges({
295
306
  namespaces: [namespace],
@@ -336,6 +347,11 @@ export function WorkloadView({
336
347
  allEvents={allEvents}
337
348
  eventsLoading={eventsLoading}
338
349
  topology={topology}
350
+ resourceFocusedK8sEvents={resourceFocusedK8sEvents}
351
+ resourceFocusedUpdates={resourceFocusedUpdates}
352
+ resourceFocusedEventsLoading={resourceFocusedEventsLoading}
353
+ resourceFocusedK8sError={resourceFocusedK8sError}
354
+ resourceFocusedUpdatesError={resourceFocusedUpdatesError}
339
355
  // Capabilities
340
356
  canUpdateSecrets={canUpdateSecrets}
341
357
  // Mutations
@@ -1,31 +0,0 @@
1
- import { type ComponentProps } from 'react'
2
- import {
3
- ResourceRendererDispatch as BaseResourceRendererDispatch,
4
- getResourceStatus,
5
- } from '@skyhook-io/k8s-ui'
6
- import { PrometheusCharts } from '../resource/PrometheusCharts'
7
- import { useResourceEvents } from '../../api/client'
8
-
9
- // Re-export getResourceStatus as-is (pure function, no wrapper needed)
10
- export { getResourceStatus }
11
-
12
- type BaseProps = ComponentProps<typeof BaseResourceRendererDispatch>
13
-
14
- export function ResourceRendererDispatch(props: Omit<BaseProps, 'events' | 'eventsLoading' | 'renderMetrics'>) {
15
- const { data: events, isLoading: eventsLoading } = useResourceEvents(
16
- props.resource.kind,
17
- props.resource.namespace,
18
- props.resource.name
19
- )
20
-
21
- return (
22
- <BaseResourceRendererDispatch
23
- {...props}
24
- events={events}
25
- eventsLoading={eventsLoading}
26
- renderMetrics={({ kind, namespace, name }) => (
27
- <PrometheusCharts kind={kind} namespace={namespace} name={name} />
28
- )}
29
- />
30
- )
31
- }