@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.
- package/package.json +4 -4
- package/src/App.tsx +69 -61
- package/src/RadarApp.tsx +15 -1
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1222 -244
- package/src/api/timelineSource.ts +4 -2
- package/src/components/applications/ApplicationsView.tsx +613 -219
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/diagnose/AISettings.tsx +7 -12
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +3 -0
- package/src/components/diagnose/InvestigationView.tsx +16 -3
- package/src/components/diagnose/parts.tsx +140 -73
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/ValuesDiffPreview.tsx +2 -3
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +319 -123
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +66 -116
- package/src/components/settings/SettingsDialog.tsx +1268 -318
- package/src/components/timeline/TimelineList.tsx +35 -8
- package/src/components/timeline/TimelineView.tsx +156 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
- package/src/components/workload/WorkloadView.tsx +711 -328
- package/src/context/DiagnoseCustomization.tsx +36 -2
- package/src/index.css +5 -1
- package/src/index.ts +4 -1
- 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
|
-
<
|
|
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
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
//
|
|
596
|
-
//
|
|
597
|
-
const
|
|
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
|
-
{
|
|
604
|
-
? "Run a read-only AI investigation?"
|
|
605
|
-
: "Run an AI investigation?"}
|
|
613
|
+
{title}
|
|
606
614
|
</div>
|
|
607
615
|
</div>
|
|
608
|
-
<
|
|
609
|
-
|
|
610
|
-
<
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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'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's read-only investigation tools — your
|
|
637
|
-
other CLI config and MCP servers are excluded.
|
|
638
|
-
{agent === "codex" && (
|
|
639
|
-
<>
|
|
640
|
-
{" "}
|
|
641
|
-
Codex'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
|
-
• “My setup”: the agent also runs with your own CLI
|
|
649
|
-
config + MCP servers and can read local files. Only Radar'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
|
-
|
|
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
|
-
|
|
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'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'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's read-only investigation tools — your
|
|
726
|
+
other CLI config and MCP servers are excluded.
|
|
727
|
+
{agent === "codex" && (
|
|
728
|
+
<>
|
|
729
|
+
{" "}
|
|
730
|
+
Codex'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
|
+
“My setup”: the agent also runs with your own CLI config
|
|
738
|
+
+ MCP servers and can read local files. Only Radar'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 [
|
|
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 = !
|
|
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)
|
|
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: () =>
|
|
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={
|
|
795
|
+
open={!!syncDialogTarget}
|
|
743
796
|
appLabel={`${namespace}/${name}`}
|
|
797
|
+
resource={syncDialogTarget?.scope === 'resource' ? syncDialogTarget.resource : undefined}
|
|
744
798
|
pending={argoSync.isPending}
|
|
745
|
-
|
|
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
|
-
|
|
748
|
-
|
|
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
|
|