@skyhook-io/radar-app 1.9.3 → 1.9.5

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.9.3",
3
+ "version": "1.9.5",
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
@@ -9,7 +9,7 @@ import { DebugOverlay } from './components/DebugOverlay'
9
9
  import { GlobalDiagnoseButton } from './components/diagnose/LocalDiagnoseAction'
10
10
  import { useDiagnoseLayout } from './components/diagnose/DiagnoseContext'
11
11
  import { DiagnoseSurface } from './components/diagnose/DiagnoseSurface'
12
- import { TopologyGraph, TopologySearch, TopologyBreadcrumb, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader } from '@skyhook-io/k8s-ui'
12
+ import { TopologyGraph, TopologySearch, TopologyBreadcrumb, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader, assetUrl } from '@skyhook-io/k8s-ui'
13
13
  import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
14
14
  import { useAPIResources, findAPIResourceForRoute } from './api/apiResources'
15
15
  import { TimelineView } from './components/timeline/TimelineView'
@@ -33,6 +33,7 @@ import { DockProvider, BottomDock, useDock, useDockReservedHeight, useOpenLocalT
33
33
  import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
34
34
  import { ContextSwitcher } from './components/ContextSwitcher'
35
35
  import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
36
+ import { CloudFunnelButton } from './components/CloudFunnelButton'
36
37
  import { useNavCustomization } from './context/NavCustomization'
37
38
  import type { FleetTakeoverTarget } from './context/NavCustomization'
38
39
  import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
@@ -68,6 +69,8 @@ import { type OmnibarHandle } from './components/ui/Omnibar'
68
69
  import { RadarOmnibar } from './components/ui/RadarOmnibar'
69
70
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
70
71
 
72
+ const radarLogoUrl = assetUrl('/images/radar/radar-icon.svg')
73
+
71
74
  // All possible node kinds (core + GitOps)
72
75
  const ALL_NODE_KINDS: NodeKind[] = [
73
76
  'Internet', 'Ingress', 'Gateway', 'HTTPRoute', 'GRPCRoute', 'TCPRoute', 'TLSRoute',
@@ -145,6 +148,8 @@ function getViewFromPath(pathname: string): ExtendedMainView {
145
148
  // breakdown; a filter would only hide rows).
146
149
  // - A GitOps detail tree spans namespaces — its controller lives in one
147
150
  // namespace but manages workloads across many.
151
+ // - Upgrade impact evaluates the full readable cluster scope rather than a
152
+ // browsing filter.
148
153
  // - A cluster-scoped resource kind (Nodes, PVs, ClusterRoles…) has no
149
154
  // namespace at all.
150
155
  // The pick itself is preserved so it re-applies when the user returns to a
@@ -170,6 +175,12 @@ function namespaceFilterDisabled(
170
175
  tooltip: 'Capacity is reported across the cluster — the namespace filter doesn’t apply here.',
171
176
  }
172
177
  }
178
+ if (view === 'checks' && pathname.startsWith('/checks/upgrade')) {
179
+ return {
180
+ disabled: true,
181
+ tooltip: 'Upgrade impact scans every namespace you can access — the namespace filter doesn’t apply.',
182
+ }
183
+ }
173
184
  const segments = pathname.replace(/^\//, '').split('/')
174
185
  if (view === 'gitops' && segments[1] === 'detail') {
175
186
  return {
@@ -233,6 +244,8 @@ function radarPageTitle(pathname: string, search = '', apiResources?: APIResourc
233
244
  return slash >= 0 && slash < decoded.length - 1 ? decoded.slice(slash + 1) : decoded
234
245
  }
235
246
 
247
+ if (view === 'checks' && pathSegments[1] === 'upgrade') return 'Upgrade impact'
248
+
236
249
  // The landing view reads "Overview" rather than "Home" in the tab.
237
250
  if (view === 'capacity') {
238
251
  if (pathSegments[1] === 'pools') return decode(pathSegments[2] ?? '') || 'Capacity'
@@ -401,6 +414,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
401
414
 
402
415
  // Get mainView from URL path
403
416
  const mainView = getViewFromPath(location.pathname)
417
+ const upgradeReadinessRoute = location.pathname.startsWith('/checks/upgrade')
404
418
 
405
419
  // Initialize the kind→plural discovery map app-wide (not just on ResourcesView
406
420
  // mount) so the omnibar can open a CRD hit with an irregular plural from any
@@ -485,7 +499,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
485
499
  // The host's URL for the CURRENT view, if taken over. Drives the redirect
486
500
  // effect and the "Opening…" splash.
487
501
  const viewTakeoverHref =
488
- mainView === 'issues' || mainView === 'gitops' || mainView === 'checks'
502
+ (mainView === 'issues' || mainView === 'gitops' || mainView === 'checks') && !upgradeReadinessRoute
489
503
  ? takeover[mainView]
490
504
  : undefined
491
505
  useEffect(() => {
@@ -698,6 +712,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
698
712
  newParams.delete('kind')
699
713
  newParams.delete('mode')
700
714
  newParams.delete('group')
715
+ newParams.delete('target')
701
716
  // Open as a normal drawer — never inherit a stale ?full=1/tab from an
702
717
  // expanded view we're navigating away from (only expand/drill set those).
703
718
  newParams.delete('full')
@@ -1866,6 +1881,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1866
1881
  {/* AI investigations (self-hides when no agent CLI is present) */}
1867
1882
  <GlobalDiagnoseButton />
1868
1883
 
1884
+ {/* Radar Cloud funnel — OSS-only chrome, same gate as the star.
1885
+ Cloud embeds render chromeless anyway; the explicit gate is
1886
+ belt-and-braces for future chrome-bearing embedders. */}
1887
+ {!navCustomization.embedded && <CloudFunnelButton />}
1888
+
1869
1889
  {/* Local terminal */}
1870
1890
  {capabilities.localTerminal && (
1871
1891
  <Tooltip content="Open local terminal">
@@ -2285,9 +2305,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2285
2305
  />
2286
2306
  )}
2287
2307
 
2288
- {/* Best practices detail view (inline only when the host hasn't taken
2289
- Checks over standalone OSS, or Cloud without a checks takeover). */}
2290
- {mainView === 'checks' && !isViewTakenOver('checks') && (
2308
+ {/* Checks detail view. Cloud can take over fleet best practices while
2309
+ the target-specific upgrade route continues to render locally. */}
2310
+ {mainView === 'checks' && (!isViewTakenOver('checks') || upgradeReadinessRoute) && (
2291
2311
  <AuditView
2292
2312
  namespaces={namespaces}
2293
2313
  onNavigateToResource={navigateToResourceList}
@@ -2554,7 +2574,7 @@ function Logo() {
2554
2574
  <div className="flex items-center gap-2.5">
2555
2575
  <div className="relative w-7 h-7 rounded-lg overflow-hidden flex-shrink-0 bg-emerald-500/10 border border-emerald-500/20">
2556
2576
  <img
2557
- src="/images/radar/radar-icon.svg"
2577
+ src={radarLogoUrl}
2558
2578
  alt=""
2559
2579
  aria-hidden
2560
2580
  className="w-full h-full p-0.5"
package/src/api/client.ts CHANGED
@@ -391,6 +391,61 @@ export interface AuditResponse {
391
391
  groupedChecks?: Check[];
392
392
  }
393
393
 
394
+ export type UpgradeReadinessVerdict = 'blocked' | 'warning' | 'review' | 'no_known_blockers' | 'unknown'
395
+ export type UpgradeReadinessLevel = 'blocker' | 'warning' | 'review'
396
+ export type UpgradeReadinessCheckStatus = 'passed' | 'blocked' | 'warning' | 'review' | 'unknown' | 'not_applicable'
397
+
398
+ export interface UpgradeReadinessResourceRef {
399
+ group?: string
400
+ kind: string
401
+ namespace: string
402
+ name: string
403
+ }
404
+
405
+ export interface UpgradeReadinessFinding {
406
+ ruleID: string
407
+ title: string
408
+ level: UpgradeReadinessLevel
409
+ resource?: UpgradeReadinessResourceRef
410
+ managedBy?: UpgradeReadinessResourceRef
411
+ evidence: { source: string; path: string; detail?: string }
412
+ appliesFrom?: string
413
+ impact: string
414
+ remediation: string
415
+ references: { title: string; url: string }[]
416
+ }
417
+
418
+ export interface UpgradeReadinessCheck {
419
+ id: string
420
+ category: string
421
+ title: string
422
+ status: UpgradeReadinessCheckStatus
423
+ summary: string
424
+ evidenceNote?: string
425
+ caveat?: string
426
+ scope: string
427
+ inspected?: number
428
+ appliesFrom?: string
429
+ findings: UpgradeReadinessFinding[]
430
+ references?: { title: string; url: string }[]
431
+ }
432
+
433
+ export interface UpgradeReadinessResponse {
434
+ currentVersion: string
435
+ targetVersion: string
436
+ reviewedThrough: string
437
+ verdict: UpgradeReadinessVerdict
438
+ summary: { blocked: number; warnings: number; reviews: number; passed: number; unknown: number; notApplicable: number; findings: number }
439
+ checks: UpgradeReadinessCheck[]
440
+ coverage: {
441
+ source: 'live'
442
+ state: 'complete' | 'partial' | 'no_access'
443
+ unavailableKinds?: string[]
444
+ scopedNamespaces?: string[]
445
+ scopedKinds?: Record<string, string[]>
446
+ }
447
+ }
448
+
394
449
  export interface DashboardCertificateHealth {
395
450
  total: number;
396
451
  healthy: number;
@@ -486,6 +541,18 @@ export function useAudit(namespaces: string[] = []) {
486
541
  });
487
542
  }
488
543
 
544
+ export function useUpgradeReadiness(target?: string) {
545
+ const params = new URLSearchParams()
546
+ if (target) params.set('target', target)
547
+ const query = params.toString()
548
+ return useQuery<UpgradeReadinessResponse>({
549
+ queryKey: ['upgrade-readiness', target ?? 'next'],
550
+ queryFn: ({ signal }) => fetchJSON(`/upgrade-readiness${query ? `?${query}` : ''}`, signal),
551
+ staleTime: 30000,
552
+ placeholderData: (previous) => previous,
553
+ })
554
+ }
555
+
489
556
  // Live cluster Issues — the grouped triage queue (radar's /api/issues =
490
557
  // internal/issues.Compose+Classify+Group). Single-cluster here; the Hub fleet
491
558
  // view fans the same shape across clusters. Do not carry old data across query
@@ -1478,6 +1545,190 @@ export function useCapabilities() {
1478
1545
  });
1479
1546
  }
1480
1547
 
1548
+ // ============================================================================
1549
+ // In-product Cloud Connect (driver lane)
1550
+ // ============================================================================
1551
+
1552
+ export interface CloudInstallRecoveryGuidance {
1553
+ summary: string
1554
+ lines?: string[]
1555
+ inspect?: string[]
1556
+ clusterUrl?: string
1557
+ }
1558
+
1559
+ export interface CloudInstallPlanSummary {
1560
+ mode: 'fresh' | 'adopt'
1561
+ contextName: string
1562
+ namespace: string
1563
+ release: string
1564
+ defaultClusterName: string
1565
+ targetChartVersion: string
1566
+ targetAppVersion: string
1567
+ currentChartVersion?: string
1568
+ currentRevision?: number
1569
+ currentImageTag?: string
1570
+ preservedImageRepository?: string
1571
+ uncertainty?: string
1572
+ advisories?: string[]
1573
+ sharedListener?: boolean
1574
+ }
1575
+
1576
+ export interface CloudInstallConnected {
1577
+ clusterId: string
1578
+ clusterUrl: string
1579
+ trackCommand: string
1580
+ rollback?: CloudInstallRecoveryGuidance
1581
+ }
1582
+
1583
+ export interface CloudInstallFailure {
1584
+ kind: string
1585
+ message: string
1586
+ guidance?: CloudInstallRecoveryGuidance
1587
+ retrySafe: boolean
1588
+ }
1589
+
1590
+ export interface CloudInstallBlocked {
1591
+ reason: 'gitops' | 'preflight' | 'unsupported'
1592
+ message: string
1593
+ blocking?: string[]
1594
+ }
1595
+
1596
+ export type CloudInstallState =
1597
+ | 'idle'
1598
+ | 'preparing'
1599
+ | 'ready'
1600
+ | 'starting'
1601
+ | 'awaiting_approval'
1602
+ | 'provisioning'
1603
+ | 'waiting_tunnel'
1604
+ | 'connected'
1605
+ | 'failed'
1606
+ | 'blocked'
1607
+
1608
+ export interface CloudInstallStatus {
1609
+ flowId?: string
1610
+ state: CloudInstallState
1611
+ plan?: CloudInstallPlanSummary
1612
+ clusterName?: string
1613
+ connectUrl?: string
1614
+ connected?: CloudInstallConnected
1615
+ failure?: CloudInstallFailure
1616
+ blocked?: CloudInstallBlocked
1617
+ }
1618
+
1619
+ export const cloudInstallActive = (state: CloudInstallState | undefined): boolean =>
1620
+ state === 'preparing' ||
1621
+ state === 'ready' ||
1622
+ state === 'starting' ||
1623
+ state === 'awaiting_approval' ||
1624
+ state === 'provisioning' ||
1625
+ state === 'waiting_tunnel';
1626
+
1627
+ // Poll while a flow is live so progress renders in near-real-time; the flow
1628
+ // itself is server-owned, so this hook merely observes (and re-attaches after
1629
+ // a page reload). gcTime 0: never cache connect-flow payloads.
1630
+ // What the Hub says about connecting to it. Fetched from the Hub itself, not
1631
+ // from Radar's API, so the dialog states current terms instead of whatever was
1632
+ // compiled into this binary months ago — a stale free-tier claim is a promise
1633
+ // Radar can't keep. Every field is optional and falls back per-field, so a Hub
1634
+ // that doesn't serve this (self-hosted, older, offline) renders exactly what
1635
+ // Radar renders today.
1636
+ export interface CloudConnectInfo {
1637
+ assurances?: string[]
1638
+ notice?: string
1639
+ }
1640
+
1641
+ // Deliberately a bare cross-origin GET: no credentials, no identifiers, no
1642
+ // params. The Hub learns only what any HTTP request reveals.
1643
+ export function useCloudConnectInfo(apiUrl: string | undefined, enabled: boolean) {
1644
+ return useQuery<CloudConnectInfo>({
1645
+ queryKey: ["cloud-connect-info", apiUrl],
1646
+ queryFn: async () => {
1647
+ const res = await fetch(`${apiUrl}/api/connect/info`, {
1648
+ credentials: "omit",
1649
+ signal: AbortSignal.timeout(4000),
1650
+ });
1651
+ if (!res.ok) throw new Error(`connect info: ${res.status}`);
1652
+ return res.json();
1653
+ },
1654
+ // No apiUrl means the server decided this deployment must not fetch.
1655
+ enabled: enabled && !!apiUrl,
1656
+ staleTime: 5 * 60 * 1000,
1657
+ retry: false,
1658
+ });
1659
+ }
1660
+
1661
+ export function useCloudInstallStatus(enabled: boolean) {
1662
+ const queryClient = useQueryClient()
1663
+ const query = useQuery<CloudInstallStatus>({
1664
+ queryKey: ['cloud-install-status'],
1665
+ queryFn: () => fetchJSON('/cloud/install/status'),
1666
+ enabled,
1667
+ gcTime: 0,
1668
+ staleTime: 0,
1669
+ refetchInterval: (q) => (cloudInstallActive(q.state.data?.state) ? 1500 : false),
1670
+ })
1671
+ return {
1672
+ ...query,
1673
+ invalidate: () => queryClient.invalidateQueries({ queryKey: ['cloud-install-status'] }),
1674
+ }
1675
+ }
1676
+
1677
+ // In-cluster Radar describing its own installation, so the modal can route to
1678
+ // the right handoff instead of a generic signup link.
1679
+ export interface CloudConnectSelf {
1680
+ ownership: 'helm' | 'gitops' | 'ambiguous' | 'unknown'
1681
+ namespace?: string
1682
+ release?: string
1683
+ deploymentName?: string
1684
+ chart?: string
1685
+ controller?: string
1686
+ wizardUrl?: string
1687
+ }
1688
+
1689
+ export function useCloudConnectSelf(enabled: boolean) {
1690
+ return useQuery<CloudConnectSelf>({
1691
+ queryKey: ['cloud-connect-self'],
1692
+ queryFn: () => fetchJSON('/cloud/connect/self'),
1693
+ enabled,
1694
+ staleTime: 60000,
1695
+ })
1696
+ }
1697
+
1698
+ export function prepareCloudInstall(): Promise<CloudInstallStatus> {
1699
+ return fetchJSON('/cloud/install/prepare', { method: 'POST' })
1700
+ }
1701
+
1702
+ export function startCloudInstall(body: {
1703
+ flowId: string
1704
+ clusterName: string
1705
+ acceptAdoption?: boolean
1706
+ acknowledgeIncompleteDiscovery?: boolean
1707
+ acknowledgeSharedListener?: boolean
1708
+ }): Promise<CloudInstallStatus> {
1709
+ return fetchJSON('/cloud/install/start', {
1710
+ method: 'POST',
1711
+ headers: { 'Content-Type': 'application/json' },
1712
+ body: JSON.stringify(body),
1713
+ })
1714
+ }
1715
+
1716
+ export function cancelCloudInstall(flowId: string): Promise<CloudInstallStatus> {
1717
+ return fetchJSON('/cloud/install/cancel', {
1718
+ method: 'POST',
1719
+ headers: { 'Content-Type': 'application/json' },
1720
+ body: JSON.stringify({ flowId }),
1721
+ })
1722
+ }
1723
+
1724
+ export function dismissCloudInstall(flowId: string): Promise<CloudInstallStatus> {
1725
+ return fetchJSON('/cloud/install/dismiss', {
1726
+ method: 'POST',
1727
+ headers: { 'Content-Type': 'application/json' },
1728
+ body: JSON.stringify({ flowId }),
1729
+ })
1730
+ }
1731
+
1481
1732
  // Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
1482
1733
  // have these permissions in specific namespaces.
1483
1734
  export function useNamespaceCapabilities(
@@ -40,6 +40,25 @@ describe('stripBasename', () => {
40
40
  expect(stripBasename('/c/abcdef/resources')).toBe('/c/abcdef/resources')
41
41
  })
42
42
 
43
+ // Standalone Radar sets a basename too once --base-path is configured, so the
44
+ // session-expiry return path round-trips through this in a new shape. Store
45
+ // strips once (client.ts) and restore strips again defensively (App.tsx) before
46
+ // navigate() re-applies the basename — the net effect must be the original
47
+ // route, never a doubled one.
48
+ it('round-trips a return path under a base path without doubling', () => {
49
+ setBasename('/radar')
50
+ const stored = stripBasename('/radar/resources/pods') + '?namespaces=prod'
51
+ expect(stored).toBe('/resources/pods?namespaces=prod')
52
+ // Restore side strips a second time; already-relative values are untouched.
53
+ expect(stripBasename(stored)).toBe('/resources/pods?namespaces=prod')
54
+ })
55
+
56
+ it('leaves a nested base path return path relative', () => {
57
+ setBasename('/tools/radar')
58
+ expect(stripBasename('/tools/radar/topology')).toBe('/topology')
59
+ expect(stripBasename(stripBasename('/tools/radar/topology'))).toBe('/topology')
60
+ })
61
+
43
62
  it('inverts routePath', () => {
44
63
  setBasename('/c/abc')
45
64
  expect(stripBasename(routePath('/auth/login'))).toBe('/auth/login')