@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 +1 -1
- package/src/App.tsx +88 -22
- package/src/api/client.ts +66 -14
- package/src/components/ContextSwitcher.tsx +8 -6
- package/src/components/resources/ResourcesView.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +16 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +0 -31
package/package.json
CHANGED
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
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
|
|
929
|
-
refetchInterval: 15000,
|
|
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 {
|
|
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.
|
|
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={
|
|
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
|
-
}
|