@skyhook-io/radar-app 1.8.7 → 1.8.9

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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +69 -61
  3. package/src/RadarApp.tsx +15 -1
  4. package/src/api/apiResources.ts +1 -1
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.rightsizing.test.ts +32 -0
  7. package/src/api/client.ts +1222 -244
  8. package/src/api/timelineSource.ts +4 -2
  9. package/src/components/applications/ApplicationsView.tsx +613 -219
  10. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  11. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  12. package/src/components/cost/CostTrendChart.tsx +103 -72
  13. package/src/components/cost/CostView.test.ts +12 -0
  14. package/src/components/cost/CostView.tsx +494 -229
  15. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  16. package/src/components/cost/CostViewTabs.tsx +40 -0
  17. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  18. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  19. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  20. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  21. package/src/components/cost/cloud-console.test.ts +39 -0
  22. package/src/components/cost/cloud-console.ts +81 -0
  23. package/src/components/cost/errors.ts +8 -0
  24. package/src/components/cost/format.test.ts +27 -0
  25. package/src/components/cost/format.ts +46 -0
  26. package/src/components/cost/kinds.ts +5 -0
  27. package/src/components/diagnose/AISettings.tsx +7 -12
  28. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  29. package/src/components/diagnose/DiagnoseSurface.tsx +3 -0
  30. package/src/components/diagnose/InvestigationView.tsx +16 -3
  31. package/src/components/diagnose/parts.tsx +140 -73
  32. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  33. package/src/components/gitops/GitOpsView.tsx +81 -14
  34. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  35. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  36. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  37. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  38. package/src/components/home/CostCard.tsx +21 -36
  39. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  40. package/src/components/resource/RightsizingStrip.tsx +319 -123
  41. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  42. package/src/components/rightsizing/copy.test.ts +56 -0
  43. package/src/components/rightsizing/model.test.ts +227 -0
  44. package/src/components/rightsizing/model.ts +158 -0
  45. package/src/components/rightsizing/presentation.test.ts +104 -0
  46. package/src/components/rightsizing/presentation.ts +94 -0
  47. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  48. package/src/components/settings/SettingsDialog.tsx +1268 -318
  49. package/src/components/timeline/TimelineList.tsx +35 -8
  50. package/src/components/timeline/TimelineView.tsx +156 -26
  51. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  52. package/src/components/workload/WorkloadView.tsx +711 -328
  53. package/src/context/DiagnoseCustomization.tsx +36 -2
  54. package/src/index.css +5 -1
  55. package/src/index.ts +4 -1
  56. package/src/main.tsx +1 -1
@@ -44,7 +44,11 @@ export function InvestigationView({
44
44
  maximized: boolean;
45
45
  }) {
46
46
  const { kind, namespace, name } = run;
47
- const { refreshRuns, openInvestigation } = useDiagnose();
47
+ const { refreshRuns, openInvestigation, startError } = useDiagnose();
48
+ const retryDiagnosis = useCallback(
49
+ () => openInvestigation({ kind, namespace, name }),
50
+ [openInvestigation, kind, namespace, name],
51
+ );
48
52
  const queryClient = useQueryClient();
49
53
  const [turns, setTurns] = useState<Turn[]>([]);
50
54
  // The run is gone server-side (evicted past the retention cap, or lost on a
@@ -505,14 +509,23 @@ export function InvestigationView({
505
509
  onApply={canApply ? requestApply : undefined}
506
510
  onAsk={isLast && !busy && !stale ? askFollowup : undefined}
507
511
  onCheckStatus={canCheck ? checkStatus : undefined}
512
+ onRetryDiagnosis={
513
+ isLast &&
514
+ t.status === "error" &&
515
+ !t.question &&
516
+ !t.apply &&
517
+ !stale
518
+ ? retryDiagnosis
519
+ : undefined
520
+ }
508
521
  hideVerdict={pinned && i === pinnedIdx}
509
522
  />
510
523
  );
511
524
  })}
512
- {actionError && (
525
+ {(actionError || startError) && (
513
526
  <div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
514
527
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
515
- <span>{actionError}</span>
528
+ <span>{actionError || startError}</span>
516
529
  </div>
517
530
  )}
518
531
  </div>
@@ -22,6 +22,7 @@ import { stringify as toYaml } from "yaml";
22
22
  import { codeToHtml } from "shiki";
23
23
  import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
24
24
  import { useTheme } from "../../context/ThemeContext";
25
+ import { type DiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
25
26
  import {
26
27
  type Diagnosis,
27
28
  type DiagnoseStep,
@@ -402,6 +403,7 @@ export function TurnView({
402
403
  onApply,
403
404
  onAsk,
404
405
  onCheckStatus,
406
+ onRetryDiagnosis,
405
407
  hideVerdict = false,
406
408
  }: {
407
409
  turn: Turn;
@@ -410,6 +412,7 @@ export function TurnView({
410
412
  onApply?: (fix: string) => void;
411
413
  onAsk?: (question: string) => void;
412
414
  onCheckStatus?: () => void;
415
+ onRetryDiagnosis?: () => void;
413
416
  // In the maximized workspace the pinned turn's verdict renders in the side rail,
414
417
  // so the transcript suppresses its own copy (reasoning + tool calls still show).
415
418
  hideVerdict?: boolean;
@@ -469,7 +472,18 @@ export function TurnView({
469
472
  {turn.status === "error" && turn.error && (
470
473
  <div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
471
474
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
472
- <span>{turn.error}</span>
475
+ <div className="flex min-w-0 flex-col gap-2">
476
+ <span className="whitespace-pre-wrap break-words">{turn.error}</span>
477
+ {onRetryDiagnosis && (
478
+ <button
479
+ type="button"
480
+ onClick={onRetryDiagnosis}
481
+ className="btn-brand self-start px-3 py-1 text-xs"
482
+ >
483
+ Retry diagnosis
484
+ </button>
485
+ )}
486
+ </div>
473
487
  </div>
474
488
  )}
475
489
  </div>
@@ -568,95 +582,51 @@ export function RunContextCard({ run }: { run: RunSummary }) {
568
582
  );
569
583
  }
570
584
 
571
- // The first-run consent + trust card. Its copy is the OSS BYO-local trust story
572
- // ("your own agent, on your machine, nothing to Radar").
573
- // TODO(cloud): this copy must become embedder-overridable for Radar Cloud, where
574
- // the agent runs in the cloud (the company's self-hosted instance + their key /
575
- // local LLM, OR our SaaS) — a different, honestly-different trust story ("runs in
576
- // your Radar Cloud, audited, managed key"). Plumb an override through the
577
- // DiagnoseCustomization seam (same place Hub overrides the entry button) before
578
- // the k8s-ui lift, so OSS and Cloud don't ship the same claim over different data
579
- // flows. This is also a natural "upgrade to Cloud" surface.
580
- export function ConsentCard({
581
- agentName,
582
- agent,
583
- isolated = true,
585
+ // ConsentCardShell owns the card chrome (icon, layout, settings link, Approve/
586
+ // Cancel) so every copy tier — the OSS default, the hosted fallback, and a host
587
+ // override — feeds the same frame instead of duplicating it.
588
+ function ConsentCardShell({
589
+ title,
590
+ body,
591
+ bullets,
592
+ settingsLabel,
593
+ approveLabel = "Approve & investigate",
584
594
  onOpenSettings,
585
595
  onApprove,
586
596
  onCancel,
587
- }: {
588
- agentName: string;
589
- agent?: string;
590
- isolated?: boolean;
597
+ }: DiagnoseConsentCopy & {
591
598
  onOpenSettings?: () => void;
592
599
  onApprove: () => void;
593
600
  onCancel: () => void;
594
601
  }) {
595
- // Cursor can't be isolated (no flag suppresses its global MCP servers), so it
596
- // gets its own honest framing rather than the isolated/my-setup pair.
597
- const isCursor = agent === "cursor-agent";
602
+ // `settingsLabel: null` hides the link outright — a host with one fixed agent
603
+ // has nothing behind it. `undefined` keeps the OSS default label.
604
+ const resolvedSettingsLabel =
605
+ settingsLabel === undefined
606
+ ? "Change the agent and how it runs in Settings"
607
+ : settingsLabel;
598
608
  return (
599
609
  <div className="rounded-lg border border-theme-border bg-theme-elevated p-4">
600
610
  <div className="mb-2 flex items-center gap-2">
601
611
  <ShieldCheck className="h-4 w-4 text-accent" />
602
612
  <div className="text-sm font-medium text-theme-text-primary">
603
- {isolated && !isCursor
604
- ? "Run a read-only AI investigation?"
605
- : "Run an AI investigation?"}
613
+ {title}
606
614
  </div>
607
615
  </div>
608
- <p className="text-sm leading-relaxed text-theme-text-secondary">
609
- This runs{" "}
610
- <span className="font-medium text-theme-text-primary">
611
- your own {agentName}
612
- </span>{" "}
613
- on your machine — no Radar cloud, no API key, no account. Radar sends
614
- this resource&apos;s spec, recent events, and pod logs to it (and on to
615
- its model provider under your account, not to Radar). Transcripts are
616
- kept in your local Radar history on this machine until cleared.
617
- {isolated && !isCursor && (
618
- <>
619
- {" "}
620
- Through Radar the agent can only{" "}
621
- <span className="font-medium">read</span> — it cannot change your
622
- cluster.
623
- </>
624
- )}
625
- </p>
626
- <ul className="mt-2 space-y-1 text-xs text-theme-text-tertiary">
627
- {isCursor ? (
628
- <li>
629
- • Through Radar the agent only <span className="font-medium">reads</span>{" "}
630
- your cluster. But Cursor also loads your own global MCP servers and
631
- Radar can&apos;t exclude them (unlike Claude or Codex), so if any of
632
- those can make changes, Cursor could use them.
633
- </li>
634
- ) : isolated ? (
635
- <li>
636
- • Isolated: only Radar&apos;s read-only investigation tools — your
637
- other CLI config and MCP servers are excluded.
638
- {agent === "codex" && (
639
- <>
640
- {" "}
641
- Codex&apos;s sandboxed shell can still <em>read</em> files on
642
- your machine (it cannot write or reach the network).
643
- </>
644
- )}
645
- </li>
646
- ) : (
647
- <li>
648
- • &ldquo;My setup&rdquo;: the agent also runs with your own CLI
649
- config + MCP servers and can read local files. Only Radar&apos;s own
650
- tools are read-only.
651
- </li>
652
- )}
653
- </ul>
654
- {onOpenSettings && (
616
+ <div className="text-sm leading-relaxed text-theme-text-secondary">{body}</div>
617
+ {bullets && bullets.length > 0 && (
618
+ <ul className="mt-2 space-y-1 text-xs text-theme-text-tertiary">
619
+ {bullets.map((b, i) => (
620
+ <li key={i}>• {b}</li>
621
+ ))}
622
+ </ul>
623
+ )}
624
+ {onOpenSettings && resolvedSettingsLabel && (
655
625
  <button
656
626
  onClick={onOpenSettings}
657
627
  className="mt-3 text-xs text-accent hover:underline"
658
628
  >
659
- Change the agent and how it runs in Settings
629
+ {resolvedSettingsLabel}
660
630
  </button>
661
631
  )}
662
632
  <div className="mt-4 flex gap-2">
@@ -670,13 +640,110 @@ export function ConsentCard({
670
640
  onClick={onApprove}
671
641
  className="flex-1 rounded-lg btn-brand py-1.5 text-sm"
672
642
  >
673
- Approve &amp; investigate
643
+ {approveLabel}
674
644
  </button>
675
645
  </div>
676
646
  </div>
677
647
  );
678
648
  }
679
649
 
650
+ // The first-run consent + trust card. The copy is checkable fact about a data
651
+ // flow — not marketing — and the wrong claim is a lie, not a typo. It resolves
652
+ // in two tiers:
653
+ //
654
+ // 1. `copy` — the embedding host tells its own trust story. Only the host
655
+ // knows where its agent runs, whose key pays, and where transcripts live,
656
+ // so any host that runs the agent somewhere other than the OSS local CLI
657
+ // MUST override rather than let Radar assert the local story over its flow.
658
+ // 2. No `copy` — the OSS bring-your-own-local-CLI default, the only tier where
659
+ // "on your machine / no Radar cloud / your account" actually holds.
660
+ export function ConsentCard({
661
+ agentName,
662
+ agent,
663
+ isolated = true,
664
+ copy,
665
+ onOpenSettings,
666
+ onApprove,
667
+ onCancel,
668
+ }: {
669
+ agentName: string;
670
+ agent?: string;
671
+ isolated?: boolean;
672
+ copy?: DiagnoseConsentCopy;
673
+ onOpenSettings?: () => void;
674
+ onApprove: () => void;
675
+ onCancel: () => void;
676
+ }) {
677
+ const chrome = { onOpenSettings, onApprove, onCancel };
678
+
679
+ // Tier 1: a host (e.g. radar-hub-web) supplied its own copy — use it verbatim.
680
+ if (copy) return <ConsentCardShell {...copy} {...chrome} />;
681
+
682
+ // Tier 2: OSS BYO-local default. Cursor can't be isolated (no flag suppresses
683
+ // its global MCP servers), so it gets its own honest framing rather than the
684
+ // isolated/my-setup pair.
685
+ const isCursor = agent === "cursor-agent";
686
+ return (
687
+ <ConsentCardShell
688
+ {...chrome}
689
+ title={
690
+ isolated && !isCursor
691
+ ? "Run a read-only AI investigation?"
692
+ : "Run an AI investigation?"
693
+ }
694
+ body={
695
+ <>
696
+ This runs{" "}
697
+ <span className="font-medium text-theme-text-primary">
698
+ your own {agentName}
699
+ </span>{" "}
700
+ on your machine — no Radar cloud, no API key, no account. Radar sends
701
+ this resource&apos;s spec, recent events, and pod logs to it (and on to
702
+ its model provider under your account, not to Radar). Transcripts are
703
+ kept in your local Radar history on this machine until cleared.
704
+ {isolated && !isCursor && (
705
+ <>
706
+ {" "}
707
+ Through Radar the agent can only{" "}
708
+ <span className="font-medium">read</span> — it cannot change your
709
+ cluster.
710
+ </>
711
+ )}
712
+ </>
713
+ }
714
+ bullets={[
715
+ isCursor ? (
716
+ <>
717
+ Through Radar the agent only{" "}
718
+ <span className="font-medium">reads</span> your cluster. But Cursor
719
+ also loads your own global MCP servers and Radar can&apos;t exclude
720
+ them (unlike Claude or Codex), so if any of those can make changes,
721
+ Cursor could use them.
722
+ </>
723
+ ) : isolated ? (
724
+ <>
725
+ Isolated: only Radar&apos;s read-only investigation tools — your
726
+ other CLI config and MCP servers are excluded.
727
+ {agent === "codex" && (
728
+ <>
729
+ {" "}
730
+ Codex&apos;s sandboxed shell can still <em>read</em> files on
731
+ your machine (it cannot write or reach the network).
732
+ </>
733
+ )}
734
+ </>
735
+ ) : (
736
+ <>
737
+ &ldquo;My setup&rdquo;: the agent also runs with your own CLI config
738
+ + MCP servers and can read local files. Only Radar&apos;s own tools
739
+ are read-only.
740
+ </>
741
+ ),
742
+ ]}
743
+ />
744
+ );
745
+ }
746
+
680
747
  // The Apply confirmation — wider than a generic confirm so the recommended fix
681
748
  // (rendered markdown) is legible, making it unambiguous what the one click does.
682
749
  export function ApplyDialog({
@@ -0,0 +1,23 @@
1
+ import { ArgoResourceDiff } from '@skyhook-io/k8s-ui'
2
+ import type { GitOpsInsightRef } from '@skyhook-io/k8s-ui'
3
+ import { useArgoResourceDiff } from '../../api/client'
4
+
5
+ interface ArgoResourceDiffLoaderProps {
6
+ appNamespace: string
7
+ appName: string
8
+ resourceRef: GitOpsInsightRef
9
+ }
10
+
11
+ // Host wrapper: fetches the Argo CD resource diff and hands the data to the
12
+ // pure k8s-ui presentation component. Mirrors the data-in-web / render-in-
13
+ // k8s-ui split the resource renderers use for their host-wired data.
14
+ export function ArgoResourceDiffLoader({ appNamespace, appName, resourceRef }: ArgoResourceDiffLoaderProps) {
15
+ const { data, isLoading, error } = useArgoResourceDiff(appNamespace, appName, resourceRef)
16
+ return (
17
+ <ArgoResourceDiff
18
+ diff={data ?? null}
19
+ loading={isLoading}
20
+ error={(error as Error | null)?.message ?? null}
21
+ />
22
+ )
23
+ }
@@ -19,6 +19,7 @@ import {
19
19
  formatGitOpsSourceUrl,
20
20
  getGitOpsResourceStatus,
21
21
  getGitOpsTool,
22
+ isArgoOperationInProgress,
22
23
  isArgoSuspendedByRadar,
23
24
  gitOpsInsightChangeKey,
24
25
  initNavigationMap,
@@ -46,8 +47,10 @@ import { useToast } from '../ui/Toast'
46
47
 
47
48
  import {
48
49
  fetchJSON,
50
+ buildArgoResourceSyncVars,
49
51
  useApplyResource,
50
52
  useArgoRefresh,
53
+ useArgoResourceValidation,
51
54
  useArgoResume,
52
55
  useArgoRollback,
53
56
  useArgoSuspend,
@@ -66,6 +69,8 @@ import { useConnection } from '../../context/ConnectionContext'
66
69
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
67
70
  import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
68
71
  import { CodeViewer } from '../ui/CodeViewer'
72
+ import { ArgoResourceDiffLoader } from './ArgoResourceDiffLoader'
73
+ import { RevisionMetaChip } from './RevisionMetaChip'
69
74
  import type { GitOpsHistoryItem } from '@skyhook-io/k8s-ui'
70
75
 
71
76
  const GITOPS_KINDS: APIResource[] = [
@@ -80,6 +85,10 @@ const GITOPS_KINDS: APIResource[] = [
80
85
  { name: 'alerts', kind: 'Alert', group: 'notification.toolkit.fluxcd.io', version: 'v1beta3', namespaced: true, verbs: ['list', 'get'], isCrd: true },
81
86
  ]
82
87
 
88
+ type ArgoSyncDialogTarget =
89
+ | { scope: 'application' }
90
+ | { scope: 'resource'; resource: GitOpsInsightRef }
91
+
83
92
  const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
84
93
 
85
94
  // Rows are the table's primary content; their poll cadence is what the toolbar
@@ -97,12 +106,15 @@ interface GitOpsViewProps {
97
106
  namespaces: string[]
98
107
  onOpenResource: (resource: SelectedResource) => void
99
108
  onClearNamespaces?: () => void
109
+ // Opens the global Settings dialog — backs the "Connect Argo CD" hint on the
110
+ // Changes tab of an Argo Application detail page.
111
+ onOpenSettings?: () => void
100
112
  }
101
113
 
102
- export function GitOpsView({ namespaces, onOpenResource, onClearNamespaces }: GitOpsViewProps) {
114
+ export function GitOpsView({ namespaces, onOpenResource, onClearNamespaces, onOpenSettings }: GitOpsViewProps) {
103
115
  const location = useLocation()
104
116
  if (location.pathname.startsWith('/gitops/detail/')) {
105
- return <GitOpsDetailView namespaces={namespaces} onOpenResource={onOpenResource} />
117
+ return <GitOpsDetailView namespaces={namespaces} onOpenResource={onOpenResource} onOpenSettings={onOpenSettings} />
106
118
  }
107
119
  return <GitOpsTableView namespaces={namespaces} onClearNamespaces={onClearNamespaces} />
108
120
  }
@@ -329,7 +341,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
329
341
  )
330
342
  }
331
343
 
332
- function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
344
+ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOpsViewProps) {
333
345
  const location = useLocation()
334
346
  const navigate = useNavigate()
335
347
  const { showError, showSuccess } = useToast()
@@ -403,6 +415,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
403
415
  const [helmValuesOpen, setHelmValuesOpen] = useState(false)
404
416
 
405
417
  const argoSync = useArgoSync()
418
+ const argoResourceValidation = useArgoResourceValidation()
406
419
  const argoRefresh = useArgoRefresh()
407
420
  const argoTerminate = useArgoTerminate()
408
421
  const argoSuspend = useArgoSuspend()
@@ -414,13 +427,23 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
414
427
  const fluxSuspend = useFluxSuspend()
415
428
  const fluxResume = useFluxResume()
416
429
 
417
- const [syncDialogOpen, setSyncDialogOpen] = useState(false)
430
+ const [syncDialogTarget, setSyncDialogTarget] = useState<ArgoSyncDialogTarget | null>(null)
418
431
  // Doubles as the "open" flag (truthy = dialog open) and the data carrier
419
432
  // for which history entry to roll back to.
420
433
  const [rollbackTarget, setRollbackTarget] = useState<GitOpsHistoryItem | null>(null)
421
434
  // Disambiguates which refresh button is in flight (both share argoRefresh).
422
435
  const [refreshKind, setRefreshKind] = useState<'normal' | 'hard'>('normal')
423
436
 
437
+ function openArgoSyncDialog(target: ArgoSyncDialogTarget) {
438
+ argoResourceValidation.reset()
439
+ setSyncDialogTarget(target)
440
+ }
441
+
442
+ function closeArgoSyncDialog() {
443
+ argoResourceValidation.reset()
444
+ setSyncDialogTarget(null)
445
+ }
446
+
424
447
  const detailRow = resourceQ.data ? normalizeDetailResource(kind, group, resourceQ.data) : null
425
448
  const tree = treeQ.data ?? null
426
449
  const helmValues = useMemo(() => extractHelmValues(kind, resourceQ.data), [kind, resourceQ.data])
@@ -436,6 +459,13 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
436
459
  function openResourceFromTree(ref: GitOpsTreeRef | GitOpsInsightRef) {
437
460
  if (isGitOpsDetailRef(ref) && isValidKubernetesName(ref.name)) {
438
461
  const detailKind = kindToPlural(ref.kind)
462
+ // The tree's root node is this page's own subject — clicking it must not
463
+ // open a nested copy of the same detail page (which stacks an identical
464
+ // "GitOps / X / X" breadcrumb, and again, ad infinitum). A self-reference
465
+ // is a no-op; the header already represents this resource.
466
+ if (detailKind === kind && (ref.namespace || '') === (namespace || '') && ref.name === name) {
467
+ return
468
+ }
439
469
  const params = new URLSearchParams()
440
470
  if (ref.group) params.set('apiGroup', ref.group)
441
471
  // Lineage breadcrumb support: when the user opens a child GitOps CR
@@ -457,13 +487,14 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
457
487
  }
458
488
 
459
489
  const isRunning = resourceQ.data?.status?.operationState?.phase === 'Running'
490
+ const operationInProgress = isArgoOperationInProgress(resourceQ.data)
460
491
  const isFluxWorkload = kind === 'kustomizations' || kind === 'helmreleases'
461
492
  const isFlux = tool === 'flux'
462
493
  const isArgoApp = kind === 'applications'
463
494
 
464
495
  // Detail-page shortcuts. Skip when a modal is already open so a stray "s"
465
496
  // in an input field doesn't pop another sync dialog.
466
- const shortcutsEnabled = !syncDialogOpen && !rollbackTarget
497
+ const shortcutsEnabled = !syncDialogTarget && !rollbackTarget
467
498
  useRegisterShortcut({
468
499
  id: 'gitops-detail-sync',
469
500
  keys: 's',
@@ -471,11 +502,11 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
471
502
  category: 'GitOps',
472
503
  scope: 'gitops',
473
504
  handler: () => {
474
- if (effectiveSuspended || terminating) return
475
- if (isArgoApp) setSyncDialogOpen(true)
505
+ if (effectiveSuspended || terminating || operationInProgress) return
506
+ if (isArgoApp) openArgoSyncDialog({ scope: 'application' })
476
507
  else if (isFlux) fluxReconcile.mutate({ kind, namespace, name })
477
508
  },
478
- enabled: shortcutsEnabled && (isArgoApp || isFlux) && !effectiveSuspended && !terminating,
509
+ enabled: shortcutsEnabled && (isArgoApp || isFlux) && !effectiveSuspended && !terminating && !(isArgoApp && operationInProgress),
479
510
  })
480
511
  useRegisterShortcut({
481
512
  id: 'gitops-detail-refresh',
@@ -529,7 +560,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
529
560
  }
530
561
 
531
562
  const argoHandlers: ArgoActionHandlers | undefined = isArgoApp ? {
532
- onSyncRequested: () => setSyncDialogOpen(true),
563
+ onSyncRequested: () => openArgoSyncDialog({ scope: 'application' }),
533
564
  onRefresh: (refreshType) => {
534
565
  setRefreshKind(refreshType)
535
566
  argoRefresh.mutate({ namespace, name, hard: refreshType === 'hard' })
@@ -545,6 +576,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
545
576
  resuming: argoResume.isPending,
546
577
  autoSyncEnabled: argoAutoSyncEnabled,
547
578
  isRunning,
579
+ operationInProgress,
548
580
  } : undefined
549
581
 
550
582
  const fluxHandlers: FluxActionHandlers | undefined = isFlux ? {
@@ -576,6 +608,13 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
576
608
  detail={detail}
577
609
  insight={insightsQ.data ?? null}
578
610
  insightLoading={insightsQ.isLoading}
611
+ renderRevisionMeta={
612
+ isArgoApp && insightsQ.data?.capabilities?.revisionMetadataAvailable
613
+ ? (revision) => (
614
+ <RevisionMetaChip appNamespace={namespace} appName={name} revision={revision} />
615
+ )
616
+ : undefined
617
+ }
579
618
  onSelectIssue={(issue) => {
580
619
  const ref = issue.refs?.[0]
581
620
  if (!ref) return
@@ -679,7 +718,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
679
718
  <GitOpsActivityInsightView
680
719
  insight={insightsQ.data}
681
720
  error={insightsQ.error as Error | null}
682
- onRollback={isArgoApp ? (item) => {
721
+ onRollback={isArgoApp && !operationInProgress ? (item) => {
683
722
  if (parseArgoRollbackID(item.id) == null) return
684
723
  setRollbackTarget(item)
685
724
  } : undefined}
@@ -692,8 +731,22 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
692
731
  insight={insightsQ.data}
693
732
  error={insightsQ.error as Error | null}
694
733
  onOpenResource={openResourceFromTree}
734
+ onSyncResource={isArgoApp ? (resource) => openArgoSyncDialog({ scope: 'resource', resource }) : undefined}
735
+ syncResourceDisabledReason={isArgoApp ? (
736
+ terminating
737
+ ? terminatingActionTooltip
738
+ : effectiveSuspended
739
+ ? 'Resume the Application before syncing a resource.'
740
+ : operationInProgress || argoSync.isPending
741
+ ? 'Wait for the current sync operation to finish.'
742
+ : undefined
743
+ ) : undefined}
695
744
  focusKey={changesFocusKey}
696
745
  tree={tree}
746
+ renderResourceDiff={isArgoApp ? (ref) => (
747
+ <ArgoResourceDiffLoader appNamespace={namespace} appName={name} resourceRef={ref} />
748
+ ) : undefined}
749
+ onOpenSettings={onOpenSettings}
697
750
  />
698
751
  )
699
752
  }
@@ -739,13 +792,27 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
739
792
  {isArgoApp && (
740
793
  <>
741
794
  <SyncOptionsDialog
742
- open={syncDialogOpen}
795
+ open={!!syncDialogTarget}
743
796
  appLabel={`${namespace}/${name}`}
797
+ resource={syncDialogTarget?.scope === 'resource' ? syncDialogTarget.resource : undefined}
744
798
  pending={argoSync.isPending}
745
- onCancel={() => setSyncDialogOpen(false)}
799
+ autoSyncEnabled={argoAutoSyncEnabled}
800
+ validationPending={argoResourceValidation.isPending}
801
+ operationInProgress={operationInProgress}
802
+ validationResult={argoResourceValidation.data}
803
+ validationError={argoResourceValidation.error?.message}
804
+ onCancel={closeArgoSyncDialog}
805
+ onValidationReset={() => argoResourceValidation.reset()}
806
+ onValidate={syncDialogTarget?.scope === 'resource' ? (opts) => {
807
+ argoResourceValidation.mutate(buildArgoResourceSyncVars(namespace, name, syncDialogTarget.resource, opts))
808
+ } : undefined}
746
809
  onConfirm={(opts) => {
747
- argoSync.mutate({ namespace, name, ...opts }, {
748
- onSettled: () => setSyncDialogOpen(false),
810
+ if (!syncDialogTarget) return
811
+ const variables = syncDialogTarget.scope === 'resource'
812
+ ? buildArgoResourceSyncVars(namespace, name, syncDialogTarget.resource, opts)
813
+ : { namespace, name, ...opts }
814
+ argoSync.mutate(variables, {
815
+ onSettled: closeArgoSyncDialog,
749
816
  })
750
817
  }}
751
818
  />
@@ -0,0 +1,63 @@
1
+ import { ShieldCheck, ShieldAlert } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+ import { useArgoRevisionMetadata } from '../../api/client'
4
+ import { Tooltip } from '../ui/Tooltip'
5
+
6
+ // Argo returns the author as "Name <email>"; show just the name.
7
+ function authorName(author?: string): string {
8
+ if (!author) return ''
9
+ const lt = author.indexOf('<')
10
+ return (lt >= 0 ? author.slice(0, lt) : author).trim()
11
+ }
12
+
13
+ // signatureInfo is a raw GPG verification line. "Good signature" → verified;
14
+ // any other non-empty value → a check ran but didn't verify; empty → unsigned.
15
+ function signatureState(sig?: string): 'good' | 'bad' | null {
16
+ if (!sig) return null
17
+ return /good signature/i.test(sig) ? 'good' : 'bad'
18
+ }
19
+
20
+ // RevisionMetaChip hydrates the Argo status-strip revision with Git commit
21
+ // detail (author, subject, signature) fetched from the Argo CD API. Renders
22
+ // nothing until data arrives, so the bare SHA is never blocked; the whole thing
23
+ // is gated by the host on capabilities.revisionMetadataAvailable.
24
+ export function RevisionMetaChip({
25
+ appNamespace,
26
+ appName,
27
+ revision,
28
+ }: {
29
+ appNamespace: string
30
+ appName: string
31
+ revision: string
32
+ }): ReactNode {
33
+ // The host only renders this chip when revision metadata is available, so no
34
+ // extra enabled gate is needed — the hook's own appName/revision guard suffices.
35
+ const { data } = useArgoRevisionMetadata(appNamespace, appName, revision)
36
+ if (!data) return null
37
+
38
+ const author = authorName(data.author)
39
+ const sig = signatureState(data.signatureInfo)
40
+ const subject = data.message?.split('\n')[0]?.trim()
41
+ if (!author && !sig && !subject) return null
42
+
43
+ return (
44
+ <>
45
+ {author && <span className="shrink-0 text-theme-text-secondary">· {author}</span>}
46
+ {subject && (
47
+ <Tooltip content={data.message ?? subject} delay={300} wrapperClassName="min-w-0">
48
+ <span className="max-w-[32ch] truncate text-theme-text-tertiary">“{subject}”</span>
49
+ </Tooltip>
50
+ )}
51
+ {sig === 'good' && (
52
+ <Tooltip content={data.signatureInfo ?? 'Signed commit'} delay={300} wrapperClassName="inline-flex shrink-0">
53
+ <ShieldCheck className="h-3 w-3 text-green-600 dark:text-green-400/80" />
54
+ </Tooltip>
55
+ )}
56
+ {sig === 'bad' && (
57
+ <Tooltip content={data.signatureInfo ?? 'Signature not verified'} delay={300} wrapperClassName="inline-flex shrink-0">
58
+ <ShieldAlert className="h-3 w-3 text-amber-600 dark:text-amber-400/80" />
59
+ </Tooltip>
60
+ )}
61
+ </>
62
+ )
63
+ }
@@ -17,7 +17,7 @@ import {
17
17
  Package,
18
18
  Settings,
19
19
  } from 'lucide-react'
20
- import { PaneLoader } from '@skyhook-io/k8s-ui'
20
+ import { PaneLoader, DiffLine, hasDiffBodyChange } from '@skyhook-io/k8s-ui'
21
21
  import {
22
22
  useCloudRole,
23
23
  useHelmHooksDiff,
@@ -30,7 +30,6 @@ import {
30
30
  import type { HelmHook, HelmRevision, HooksDiff, ResourceDiff } from '../../types'
31
31
  import { getHelmStatusColor, getKindBadgeColor, SEVERITY_BADGE } from '../../utils/badge-colors'
32
32
  import { formatDate } from './helm-utils'
33
- import { DiffLine, hasDiffBodyChange } from './ManifestDiffViewer'
34
33
  import { RoleGatedPanel } from './RoleGatedPanel'
35
34
  import { Tooltip } from '../ui/Tooltip'
36
35