@skyhook-io/radar-app 1.12.2 → 1.12.3

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.
Files changed (41) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +34 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +208 -33
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  16. package/src/components/gitops/GitOpsView.tsx +8 -3
  17. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  18. package/src/components/helm/OwnedResources.tsx +10 -2
  19. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  20. package/src/components/home/ClusterHealthCard.tsx +60 -1
  21. package/src/components/home/HomeView.tsx +36 -11
  22. package/src/components/home/MCPSetupDialog.tsx +5 -4
  23. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  24. package/src/components/home/RadarVersionLine.tsx +137 -0
  25. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  26. package/src/components/resources/ResourcesView.tsx +31 -8
  27. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  28. package/src/components/settings/SettingsDialog.tsx +21 -4
  29. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  30. package/src/components/ui/ErrorBoundary.tsx +17 -2
  31. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  32. package/src/components/ui/UpdateNotification.tsx +6 -2
  33. package/src/components/workload/WorkloadView.test.ts +60 -0
  34. package/src/components/workload/WorkloadView.tsx +270 -29
  35. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  36. package/src/contexts/CapabilitiesContext.tsx +7 -3
  37. package/src/utils/navigation.test.ts +45 -0
  38. package/src/utils/navigation.ts +5 -5
  39. package/src/utils/topology-selection.ts +3 -2
  40. package/src/utils/version.test.ts +37 -0
  41. package/src/utils/version.ts +56 -0
@@ -0,0 +1,78 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { markDailyUpdateCheckAttempt, triggerDailyUpdateCheck, utcDay } from './client'
3
+
4
+ function memoryStorage() {
5
+ const values = new Map<string, string>()
6
+ return {
7
+ getItem: (key: string) => values.get(key) ?? null,
8
+ setItem: (key: string, value: string) => values.set(key, value),
9
+ }
10
+ }
11
+
12
+ describe('daily update checks', () => {
13
+ it('uses UTC days', () => {
14
+ expect(utcDay(new Date('2026-08-29T23:59:59-07:00'))).toBe('2026-08-30')
15
+ })
16
+
17
+ it('attempts once per API base and UTC day', () => {
18
+ const storage = memoryStorage()
19
+ expect(markDailyUpdateCheckAttempt(storage, '/api', new Date('2026-08-29T10:00:00Z'))).toBe(true)
20
+ expect(markDailyUpdateCheckAttempt(storage, '/api', new Date('2026-08-29T20:00:00Z'))).toBe(false)
21
+ expect(markDailyUpdateCheckAttempt(storage, '/api', new Date('2026-08-30T10:00:00Z'))).toBe(true)
22
+ expect(markDailyUpdateCheckAttempt(storage, '/c/cluster-a/api', new Date('2026-08-30T10:00:00Z'))).toBe(true)
23
+ })
24
+
25
+ it('skips the attempt when storage is unavailable', () => {
26
+ const storage = {
27
+ getItem: () => null,
28
+ setItem: () => { throw new Error('blocked') },
29
+ }
30
+ expect(markDailyUpdateCheckAttempt(storage, '/api', new Date('2026-08-29T10:00:00Z'))).toBe(false)
31
+ })
32
+ })
33
+
34
+ describe('triggerDailyUpdateCheck', () => {
35
+ beforeEach(() => {
36
+ const values = new Map<string, string>()
37
+ vi.stubGlobal('localStorage', {
38
+ getItem: (key: string) => values.get(key) ?? null,
39
+ setItem: (key: string, value: string) => values.set(key, value),
40
+ })
41
+ })
42
+
43
+ afterEach(() => {
44
+ vi.unstubAllGlobals()
45
+ })
46
+
47
+ it.each(['local', 'cloud', undefined] as const)('does not report in %s mode', async (mode) => {
48
+ const fetch = vi.fn<typeof globalThis.fetch>(async () => new Response(null, { status: 204 }))
49
+ vi.stubGlobal('fetch', fetch)
50
+ await triggerDailyUpdateCheck(mode)
51
+ expect(fetch).not.toHaveBeenCalled()
52
+ })
53
+
54
+ it('sends one bodyless in-cluster attempt per day', async () => {
55
+ const fetch = vi.fn<typeof globalThis.fetch>(async () => new Response(null, { status: 204 }))
56
+ vi.stubGlobal('fetch', fetch)
57
+
58
+ await triggerDailyUpdateCheck('in-cluster')
59
+ await triggerDailyUpdateCheck('in-cluster')
60
+
61
+ expect(fetch).toHaveBeenCalledOnce()
62
+ expect(fetch).toHaveBeenCalledWith('/api/version-check/browser', expect.objectContaining({
63
+ method: 'POST',
64
+ credentials: 'same-origin',
65
+ keepalive: true,
66
+ }))
67
+ expect(fetch.mock.calls[0][1]).not.toHaveProperty('body')
68
+ })
69
+
70
+ it('does not retry a failed daily attempt', async () => {
71
+ const fetch = vi.fn<typeof globalThis.fetch>(async () => { throw new Error('offline') })
72
+ vi.stubGlobal('fetch', fetch)
73
+
74
+ await expect(triggerDailyUpdateCheck('in-cluster')).rejects.toThrow('offline')
75
+ await triggerDailyUpdateCheck('in-cluster')
76
+ expect(fetch).toHaveBeenCalledOnce()
77
+ })
78
+ })
@@ -1,6 +1,7 @@
1
1
  import { useRef, useState } from 'react'
2
2
  import { useMutation } from '@tanstack/react-query'
3
3
  import { AlertTriangle, ArrowUpRight, Check, ExternalLink, GitBranch, Info, Loader2, ShieldAlert, X } from 'lucide-react'
4
+ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
4
5
  import {
5
6
  ApiError,
6
7
  cancelCloudInstall,
@@ -40,7 +41,7 @@ export function CloudConnectFlow({
40
41
  <div className="px-8 py-10 flex flex-col items-center gap-3 text-center">
41
42
  <Loader2 className="w-5 h-5 animate-spin text-emerald-600 dark:text-emerald-400" />
42
43
  <p className="text-[13px] text-theme-text-secondary">
43
- Checking this cluster and preparing the install this can take a moment on a slow link.
44
+ Checking this cluster and preparing the install. This can take a moment on a slow link.
44
45
  </p>
45
46
  </div>
46
47
  )
@@ -143,6 +144,7 @@ function PlanCard({
143
144
  const [acceptAdoption, setAcceptAdoption] = useState(false)
144
145
  const [ackUncertainty, setAckUncertainty] = useState(false)
145
146
  const [ackShared, setAckShared] = useState(false)
147
+ const [notesOpen, setNotesOpen] = useState(false)
146
148
 
147
149
  // The approval tab is opened synchronously by the click below (popup
148
150
  // blockers reject window.open from an async callback) and navigated once the
@@ -236,19 +238,27 @@ function PlanCard({
236
238
  their presence visible so nobody approves blind, but don't let the
237
239
  wall of text bury the decision. */}
238
240
  {plan.advisories && plan.advisories.length > 0 && (
239
- <details className="mt-2.5">
240
- <summary className="flex items-center gap-1.5 cursor-pointer select-none text-[11.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
241
+ <div className="mt-2.5">
242
+ <button
243
+ type="button"
244
+ onClick={() => setNotesOpen((v) => !v)}
245
+ aria-expanded={notesOpen}
246
+ className="flex items-center gap-1.5 text-[11.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
247
+ >
248
+ <CollapseChevron open={notesOpen} className="w-3.5 h-3.5" />
241
249
  <Info className="w-3.5 h-3.5 shrink-0" />
242
250
  {plan.advisories.length === 1 ? '1 preflight note' : `${plan.advisories.length} preflight notes`}
243
- </summary>
244
- <ul className="mt-1.5 space-y-1.5 pl-5">
245
- {plan.advisories.map((note) => (
246
- <li key={note} className="text-[11.5px] leading-snug text-theme-text-tertiary">
247
- {note}
248
- </li>
249
- ))}
250
- </ul>
251
- </details>
251
+ </button>
252
+ <Collapse open={notesOpen}>
253
+ <ul className="mt-1.5 space-y-1.5 pl-5">
254
+ {plan.advisories.map((note) => (
255
+ <li key={note} className="text-[11.5px] leading-snug text-theme-text-tertiary">
256
+ {note}
257
+ </li>
258
+ ))}
259
+ </ul>
260
+ </Collapse>
261
+ </div>
252
262
  )}
253
263
 
254
264
  <label className="mt-3.5 block">
@@ -312,8 +322,9 @@ function PlanCard({
312
322
  * so this cannot assert whether an account already exists. Phrased to
313
323
  * read correctly for a returning operator and a first-time one alike. */}
314
324
  <p className="mt-2.5 text-[11px] text-theme-text-tertiary">
315
- Nothing is installed yet — you'll approve this cluster in the browser, creating your account and
316
- organization first if you don't have one. Then Radar installs the agent.
325
+ Nothing is installed yet. You'll approve this cluster in the browser, creating your account and
326
+ organization first if you don't have one.{' '}
327
+ {adopt ? 'Then your existing Radar is upgraded and connected.' : 'Then Radar is installed in your cluster.'}
317
328
  </p>
318
329
  </div>
319
330
  )
@@ -365,7 +376,7 @@ function ApprovalCard({ status, onStatus }: { status: CloudInstallStatus; onStat
365
376
  </div>
366
377
  <p className="text-[12.5px] leading-relaxed text-theme-text-secondary mb-3.5">
367
378
  Approve connecting <b className="text-theme-text-primary">{status.clusterName}</b> in the browser tab.
368
- Sign-in and org setup happen there too this screen advances automatically.
379
+ Sign-in and org setup happen there too, and this screen advances automatically.
369
380
  </p>
370
381
  {status.connectUrl && (
371
382
  <div className="card-inner flex items-center gap-2">
@@ -391,7 +402,7 @@ function ProgressCard({ status, onStatus }: { status: CloudInstallStatus; onStat
391
402
  const steps: Array<{ label: string; state: 'done' | 'active' | 'todo' }> = [
392
403
  { label: 'Approved in browser', state: 'done' },
393
404
  { label: `Installing Radar (namespace ${status.plan?.namespace ?? 'radar'})`, state: provisioning ? 'active' : 'done' },
394
- { label: 'Waiting for the agent to connect', state: provisioning ? 'todo' : 'active' },
405
+ { label: 'Waiting for Radar to connect to Cloud', state: provisioning ? 'todo' : 'active' },
395
406
  ]
396
407
  return (
397
408
  <div className="px-8 pt-6 pb-5">
@@ -419,7 +430,7 @@ function ProgressCard({ status, onStatus }: { status: CloudInstallStatus; onStat
419
430
  <div className="mt-4">
420
431
  {provisioning ? (
421
432
  <p className="text-[11px] text-theme-text-tertiary">
422
- Installing this step completes atomically and can’t be canceled midway.
433
+ Installing. This step completes atomically and can’t be canceled midway.
423
434
  </p>
424
435
  ) : (
425
436
  cancel
@@ -469,8 +480,8 @@ function ConnectedCard({
469
480
  </h4>
470
481
  </div>
471
482
  <p className="text-[12.5px] leading-relaxed text-theme-text-secondary mb-4">
472
- The in-cluster agent is live and tunneled. This local app keeps working exactly as before — the
473
- cluster is now also reachable for your team at one URL.
483
+ Radar is live in the cluster and connected to Radar Cloud. The Radar you're running keeps working,
484
+ and the cluster is now also reachable for your team at one URL.
474
485
  </p>
475
486
  <div className="flex items-center gap-4 mb-4">
476
487
  <a
@@ -592,14 +603,23 @@ function GuidanceBlock({
592
603
  }
593
604
 
594
605
  function GuidanceDetails({ title, guidance }: { title: string; guidance: CloudInstallRecoveryGuidance }) {
606
+ const [open, setOpen] = useState(false)
595
607
  return (
596
- <details className="group">
597
- <summary className="cursor-pointer text-[11.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors select-none">
608
+ <div>
609
+ <button
610
+ type="button"
611
+ onClick={() => setOpen((v) => !v)}
612
+ aria-expanded={open}
613
+ className="flex items-center gap-1.5 text-[11.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
614
+ >
615
+ <CollapseChevron open={open} className="w-3.5 h-3.5" />
598
616
  {title}
599
- </summary>
600
- <div className="mt-2">
601
- <GuidanceBlock guidance={guidance} />
602
- </div>
603
- </details>
617
+ </button>
618
+ <Collapse open={open}>
619
+ <div className="mt-2">
620
+ <GuidanceBlock guidance={guidance} />
621
+ </div>
622
+ </Collapse>
623
+ </div>
604
624
  )
605
625
  }