@skyhook-io/radar-app 1.10.0 → 1.12.2
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 +10 -10
- package/src/App.tsx +5 -8
- package/src/api/client.ts +174 -12
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +166 -2
- package/src/components/ConnectionErrorView.test.tsx +53 -0
- package/src/components/ConnectionErrorView.tsx +15 -12
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/audit/UpgradeReadinessView.tsx +3 -3
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/home/ClusterHealthCard.tsx +3 -0
- package/src/components/home/CostCard.tsx +12 -7
- package/src/components/home/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +27 -5
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +160 -36
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +150 -58
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- package/src/utils/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- package/src/utils/topology-selection.ts +39 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- package/src/utils/wails-clipboard.ts +127 -0
|
@@ -1,46 +1,97 @@
|
|
|
1
1
|
export const COST_HOURS_PER_DAY = 24
|
|
2
2
|
export const COST_HOURS_PER_MONTH = 730
|
|
3
|
+
export const DEFAULT_COST_CURRENCY = 'USD'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
type CurrencyFormat = { formatter: Intl.NumberFormat; prefix: string }
|
|
6
|
+
|
|
7
|
+
const currencyFormatters = new Map<string, CurrencyFormat>()
|
|
8
|
+
|
|
9
|
+
function currencyFormatter(currency: string | undefined, digits?: number): CurrencyFormat {
|
|
10
|
+
const normalized = (currency ?? '').trim().toUpperCase() || DEFAULT_COST_CURRENCY
|
|
11
|
+
const key = `${normalized}:${digits ?? 'default'}`
|
|
12
|
+
const cached = currencyFormatters.get(key)
|
|
13
|
+
if (cached) return cached
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const options: Intl.NumberFormatOptions = {
|
|
17
|
+
style: 'currency',
|
|
18
|
+
currency: normalized,
|
|
19
|
+
}
|
|
20
|
+
if (digits !== undefined) {
|
|
21
|
+
options.minimumFractionDigits = digits
|
|
22
|
+
options.maximumFractionDigits = digits
|
|
23
|
+
}
|
|
24
|
+
const formatter = new Intl.NumberFormat('en-US', options)
|
|
25
|
+
const result = { formatter, prefix: '' }
|
|
26
|
+
currencyFormatters.set(key, result)
|
|
27
|
+
return result
|
|
28
|
+
} catch {
|
|
29
|
+
const fallbackDigits = digits ?? 2
|
|
30
|
+
const options: Intl.NumberFormatOptions = {
|
|
31
|
+
minimumFractionDigits: fallbackDigits,
|
|
32
|
+
maximumFractionDigits: fallbackDigits,
|
|
33
|
+
}
|
|
34
|
+
const result = {
|
|
35
|
+
formatter: new Intl.NumberFormat('en-US', options),
|
|
36
|
+
prefix: `${normalized} `,
|
|
37
|
+
}
|
|
38
|
+
currencyFormatters.set(key, result)
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatCurrency(value: number, currency: string | undefined, digits?: number): string {
|
|
44
|
+
const { formatter, prefix } = currencyFormatter(currency, digits)
|
|
45
|
+
return `${prefix}${formatter.format(value)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatCostAxis(value: number, currency: string | undefined): string {
|
|
49
|
+
if (!Number.isFinite(value) || value <= 0) return formatCurrency(0, currency, 0)
|
|
50
|
+
if (value >= 1000) return `${formatCurrency(value / 1000, currency, 0)}k`
|
|
51
|
+
if (value >= 1) return formatCurrency(value, currency, 1)
|
|
52
|
+
if (value >= 0.01) return formatCurrency(value, currency, 2)
|
|
53
|
+
if (value >= 0.0001) return formatCurrency(value, currency, 4)
|
|
54
|
+
if (value >= 0.00001) return formatCurrency(value, currency, 5)
|
|
55
|
+
return `<${formatCurrency(0.00001, currency, 5)}`
|
|
12
56
|
}
|
|
13
57
|
|
|
14
|
-
export function formatCost(value: number): string {
|
|
15
|
-
if (!Number.isFinite(value) || value <= 0) return
|
|
16
|
-
if (value >= 1000) return
|
|
17
|
-
if (value >= 1) return
|
|
18
|
-
if (value >= 0.01) return
|
|
19
|
-
if (value >= 0.0001) return
|
|
20
|
-
return formatCostAxis(value)
|
|
58
|
+
export function formatCost(value: number, currency: string | undefined): string {
|
|
59
|
+
if (!Number.isFinite(value) || value <= 0) return formatCurrency(0, currency)
|
|
60
|
+
if (value >= 1000) return `${formatCurrency(value / 1000, currency, 1)}k`
|
|
61
|
+
if (value >= 1) return formatCurrency(value, currency)
|
|
62
|
+
if (value >= 0.01) return formatCurrency(value, currency, 3)
|
|
63
|
+
if (value >= 0.0001) return formatCurrency(value, currency, 4)
|
|
64
|
+
return formatCostAxis(value, currency)
|
|
21
65
|
}
|
|
22
66
|
|
|
23
|
-
export function formatCostPerHour(value: number): string {
|
|
24
|
-
return `${formatCost(value)}/hr`
|
|
67
|
+
export function formatCostPerHour(value: number, currency: string | undefined): string {
|
|
68
|
+
return `${formatCost(value, currency)}/hr`
|
|
25
69
|
}
|
|
26
70
|
|
|
27
|
-
export function formatHistoricalSpend(
|
|
71
|
+
export function formatHistoricalSpend(
|
|
72
|
+
pointCount: number,
|
|
73
|
+
windowTotalCost: number,
|
|
74
|
+
unavailable: boolean,
|
|
75
|
+
currency: string | undefined,
|
|
76
|
+
): string {
|
|
28
77
|
if (unavailable || pointCount < 2) return '—'
|
|
29
|
-
return windowTotalCost > 0
|
|
78
|
+
return windowTotalCost > 0
|
|
79
|
+
? `~${formatCost(windowTotalCost, currency)}`
|
|
80
|
+
: formatCost(0, currency)
|
|
30
81
|
}
|
|
31
82
|
|
|
32
|
-
export function formatProjectedDailyCost(hourlyCost: number): string {
|
|
33
|
-
return `~${formatCost(hourlyCost * COST_HOURS_PER_DAY)}`
|
|
83
|
+
export function formatProjectedDailyCost(hourlyCost: number, currency: string | undefined): string {
|
|
84
|
+
return `~${formatCost(hourlyCost * COST_HOURS_PER_DAY, currency)}`
|
|
34
85
|
}
|
|
35
86
|
|
|
36
|
-
export function formatProjectedDailyRate(hourlyCost: number): string {
|
|
37
|
-
return `${formatProjectedDailyCost(hourlyCost)}/day`
|
|
87
|
+
export function formatProjectedDailyRate(hourlyCost: number, currency: string | undefined): string {
|
|
88
|
+
return `${formatProjectedDailyCost(hourlyCost, currency)}/day`
|
|
38
89
|
}
|
|
39
90
|
|
|
40
|
-
export function formatProjectedMonthlyCost(hourlyCost: number): string {
|
|
41
|
-
return `~${formatCost(hourlyCost * COST_HOURS_PER_MONTH)}`
|
|
91
|
+
export function formatProjectedMonthlyCost(hourlyCost: number, currency: string | undefined): string {
|
|
92
|
+
return `~${formatCost(hourlyCost * COST_HOURS_PER_MONTH, currency)}`
|
|
42
93
|
}
|
|
43
94
|
|
|
44
|
-
export function formatProjectedMonthlyRate(hourlyCost: number): string {
|
|
45
|
-
return `${formatProjectedMonthlyCost(hourlyCost)}/mo`
|
|
95
|
+
export function formatProjectedMonthlyRate(hourlyCost: number, currency: string | undefined): string {
|
|
96
|
+
return `${formatProjectedMonthlyCost(hourlyCost, currency)}/mo`
|
|
46
97
|
}
|
|
@@ -90,6 +90,9 @@ function getPlatformInfo(platform: string): { name: string; icon: string | null
|
|
|
90
90
|
if (platformLower.includes('openshift')) {
|
|
91
91
|
return { name: 'OpenShift', icon: null }
|
|
92
92
|
}
|
|
93
|
+
if (platformLower.includes('rke2')) {
|
|
94
|
+
return { name: 'RKE2', icon: null }
|
|
95
|
+
}
|
|
93
96
|
if (platformLower.includes('rancher')) {
|
|
94
97
|
return { name: 'Rancher', icon: null }
|
|
95
98
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { OpenCostSummary } from '../../api/client'
|
|
2
2
|
import { useOpenCostSummary } from '../../api/client'
|
|
3
|
-
import {
|
|
3
|
+
import { Coins } from 'lucide-react'
|
|
4
4
|
import {
|
|
5
|
+
DEFAULT_COST_CURRENCY,
|
|
5
6
|
formatCostPerHour,
|
|
6
7
|
formatProjectedDailyRate,
|
|
7
8
|
formatProjectedMonthlyCost,
|
|
@@ -21,6 +22,7 @@ export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
|
|
|
21
22
|
|
|
22
23
|
function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNavigate?: () => void }) {
|
|
23
24
|
const hourlyCost = data.totalHourlyCost ?? 0
|
|
25
|
+
const currency = data.currency ?? DEFAULT_COST_CURRENCY
|
|
24
26
|
const namespaces = data.namespaces ?? []
|
|
25
27
|
const topNamespaces = namespaces.slice(0, 5)
|
|
26
28
|
|
|
@@ -35,7 +37,7 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
|
|
|
35
37
|
<div className="flex flex-col h-full w-full">
|
|
36
38
|
<div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
|
|
37
39
|
<div className="flex items-center gap-2">
|
|
38
|
-
<
|
|
40
|
+
<Coins className="w-4 h-4 text-accent-text" />
|
|
39
41
|
<span className="text-xs font-semibold uppercase tracking-wider text-accent-text">Cost Insights</span>
|
|
40
42
|
{namespaces.length > 0 && (
|
|
41
43
|
<span className="badge-sm border border-theme-border bg-accent-muted text-accent-text">
|
|
@@ -50,14 +52,14 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
|
|
|
50
52
|
<div className="flex items-baseline gap-3 mb-3">
|
|
51
53
|
<div className="flex items-baseline gap-1">
|
|
52
54
|
<span className="text-2xl font-bold text-theme-text-primary tabular-nums">
|
|
53
|
-
{formatProjectedMonthlyCost(hourlyCost)}
|
|
55
|
+
{formatProjectedMonthlyCost(hourlyCost, currency)}
|
|
54
56
|
</span>
|
|
55
57
|
<span className="text-xs text-theme-text-tertiary">/mo</span>
|
|
56
58
|
</div>
|
|
57
59
|
<div className="flex items-baseline gap-1.5 text-theme-text-secondary">
|
|
58
|
-
<span className="text-xs font-medium tabular-nums">{formatProjectedDailyRate(hourlyCost)}</span>
|
|
60
|
+
<span className="text-xs font-medium tabular-nums">{formatProjectedDailyRate(hourlyCost, currency)}</span>
|
|
59
61
|
<span className="text-[10px] text-theme-text-quaternary">·</span>
|
|
60
|
-
<span className="text-xs font-medium tabular-nums">{formatCostPerHour(hourlyCost)}</span>
|
|
62
|
+
<span className="text-xs font-medium tabular-nums">{formatCostPerHour(hourlyCost, currency)}</span>
|
|
61
63
|
</div>
|
|
62
64
|
</div>
|
|
63
65
|
|
|
@@ -72,7 +74,7 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
|
|
|
72
74
|
<div className="h-full rounded-full bg-indigo-500/60" style={{ width: `${Math.max(pct, 2)}%` }} />
|
|
73
75
|
</div>
|
|
74
76
|
<span className="text-[10px] text-theme-text-tertiary tabular-nums w-20 text-right shrink-0">
|
|
75
|
-
{formatProjectedMonthlyRate(ns.hourlyCost)}
|
|
77
|
+
{formatProjectedMonthlyRate(ns.hourlyCost, currency)}
|
|
76
78
|
</span>
|
|
77
79
|
</div>
|
|
78
80
|
)
|
|
@@ -85,7 +87,10 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
|
|
|
85
87
|
|
|
86
88
|
<div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-between">
|
|
87
89
|
<span className="text-[10px] text-theme-text-tertiary">
|
|
88
|
-
{
|
|
90
|
+
{currency} · projected monthly from {data.window ?? '1h'} window
|
|
91
|
+
{currency !== DEFAULT_COST_CURRENCY && (
|
|
92
|
+
<> · no conversion</>
|
|
93
|
+
)}
|
|
89
94
|
</span>
|
|
90
95
|
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-accent-text">
|
|
91
96
|
OpenCost
|
|
@@ -14,6 +14,7 @@ import { GitOpsControllersCard } from './GitOpsControllersCard'
|
|
|
14
14
|
import { CapacityCard } from './CapacityCard'
|
|
15
15
|
import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
|
|
16
16
|
import { Tooltip } from '../ui/Tooltip'
|
|
17
|
+
import { getNetworkPolicyResourceTarget } from '../../utils/navigation'
|
|
17
18
|
import {
|
|
18
19
|
AuditCard,
|
|
19
20
|
FreshnessControl,
|
|
@@ -73,8 +74,13 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
|
|
|
73
74
|
})
|
|
74
75
|
const nodeIds = new Set(nodes.map(n => n.id))
|
|
75
76
|
const edges = topology.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
|
76
|
-
|
|
77
|
+
// Carry the frame's own flags through the filter. A large cluster serves an
|
|
78
|
+
// empty graph flagged requiresNamespaceFilter until SSE picks the pick up
|
|
79
|
+
// server-side, and a consumer that sees only nodes/edges reads that
|
|
80
|
+
// declined build as a namespace holding nothing.
|
|
81
|
+
return { ...topology, nodes, edges }
|
|
77
82
|
}, [topology, namespaces])
|
|
83
|
+
const networkPolicyTarget = getNetworkPolicyResourceTarget(scopedTopology)
|
|
78
84
|
// CRDs and Helm load lazily after main dashboard to keep initial load fast
|
|
79
85
|
const { data: crdsData } = useDashboardCRDs(namespaces)
|
|
80
86
|
const { data: helmData } = useDashboardHelm(namespaces)
|
|
@@ -150,7 +156,7 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
|
|
|
150
156
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
151
157
|
<TopologyPreview
|
|
152
158
|
topology={scopedTopology}
|
|
153
|
-
|
|
159
|
+
namespaceSelected={namespaces.length > 0}
|
|
154
160
|
onNavigate={() => onNavigateToView('topology')}
|
|
155
161
|
/>
|
|
156
162
|
<ActivitySummary
|
|
@@ -198,7 +204,13 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
|
|
|
198
204
|
<BandItem>
|
|
199
205
|
<NetworkPolicyCoverageCard
|
|
200
206
|
data={data.networkPolicyCoverage}
|
|
201
|
-
onNavigate={() =>
|
|
207
|
+
onNavigate={() => {
|
|
208
|
+
if (networkPolicyTarget) {
|
|
209
|
+
onNavigateToResourceKind(networkPolicyTarget.kind, networkPolicyTarget.group)
|
|
210
|
+
} else {
|
|
211
|
+
onNavigateToView('resources')
|
|
212
|
+
}
|
|
213
|
+
}}
|
|
202
214
|
/>
|
|
203
215
|
</BandItem>
|
|
204
216
|
)}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { NetworkPolicyCoverageCard } from "./NetworkPolicyCoverageCard";
|
|
5
|
+
|
|
6
|
+
// React separates adjacent text nodes with comment markers. Strip them so an
|
|
7
|
+
// assertion matches the text a reader sees instead of a style-attribute value
|
|
8
|
+
// that happens to contain the same characters.
|
|
9
|
+
const render = (element: React.ReactElement) =>
|
|
10
|
+
renderToString(element).replaceAll("<!-- -->", "");
|
|
11
|
+
|
|
12
|
+
// The card's own accent turns red at low coverage, so an assertion has to look
|
|
13
|
+
// at the striped staged segment rather than at the whole card.
|
|
14
|
+
const stagedSegmentClass = (html: string) => {
|
|
15
|
+
const match = html.match(/class="([^"]*)"[^>]*repeating-linear-gradient/);
|
|
16
|
+
if (!match) throw new Error("no staged segment rendered");
|
|
17
|
+
return match[1];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("NetworkPolicyCoverageCard", () => {
|
|
21
|
+
it("shows enforced and staged preview coverage separately", () => {
|
|
22
|
+
const html = render(
|
|
23
|
+
<NetworkPolicyCoverageCard
|
|
24
|
+
data={{
|
|
25
|
+
totalPolicies: 6,
|
|
26
|
+
stagedPolicies: 2,
|
|
27
|
+
coveredWorkloads: 3,
|
|
28
|
+
coveredWorkloadsIfStaged: 5,
|
|
29
|
+
totalWorkloads: 10,
|
|
30
|
+
}}
|
|
31
|
+
onNavigate={() => {}}
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(html).toContain("30%");
|
|
36
|
+
expect(html).toContain("(50% if staged applied)");
|
|
37
|
+
expect(html).toContain("Covered workloads");
|
|
38
|
+
expect(html).toContain("Covered if staged");
|
|
39
|
+
expect(html).toContain("Uncovered workloads");
|
|
40
|
+
expect(html).toContain("Uncovered if staged");
|
|
41
|
+
// The delta segment's tone carries the direction: amber for coverage the
|
|
42
|
+
// staged set would add. Its wording lives in a Tooltip portal, which server
|
|
43
|
+
// rendering does not emit, so the class is what an assertion can reach.
|
|
44
|
+
expect(stagedSegmentClass(html)).toContain("text-yellow-500");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("shows coverage going down when a staged policy stages a deletion", () => {
|
|
48
|
+
const html = render(
|
|
49
|
+
<NetworkPolicyCoverageCard
|
|
50
|
+
data={{
|
|
51
|
+
totalPolicies: 6,
|
|
52
|
+
stagedPolicies: 1,
|
|
53
|
+
coveredWorkloads: 8,
|
|
54
|
+
coveredWorkloadsIfStaged: 5,
|
|
55
|
+
totalWorkloads: 10,
|
|
56
|
+
}}
|
|
57
|
+
onNavigate={() => {}}
|
|
58
|
+
/>,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(html).toContain("80%");
|
|
62
|
+
expect(html).toContain("(50% if staged applied)");
|
|
63
|
+
// Red, not amber: this staged set takes coverage away.
|
|
64
|
+
expect(stagedSegmentClass(html)).toContain("text-red-500");
|
|
65
|
+
// The projected figure must not be clamped up to today's coverage.
|
|
66
|
+
expect(html).toContain("5/10");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps the original enforced-only presentation without staged policies", () => {
|
|
70
|
+
const html = render(
|
|
71
|
+
<NetworkPolicyCoverageCard
|
|
72
|
+
data={{ totalPolicies: 2, coveredWorkloads: 2, totalWorkloads: 4 }}
|
|
73
|
+
onNavigate={() => {}}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(html).toContain("50%");
|
|
78
|
+
expect(html).not.toContain("if staged");
|
|
79
|
+
expect(html).not.toContain("repeating-linear-gradient");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DashboardNetworkPolicyCoverage } from '../../api/client'
|
|
2
2
|
import { ShieldCheck, ArrowRight } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
4
5
|
|
|
5
6
|
interface NetworkPolicyCoverageCardProps {
|
|
6
7
|
data: DashboardNetworkPolicyCoverage
|
|
@@ -8,9 +9,23 @@ interface NetworkPolicyCoverageCardProps {
|
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export function NetworkPolicyCoverageCard({ data, onNavigate }: NetworkPolicyCoverageCardProps) {
|
|
12
|
+
const hasStagedPolicies = (data.stagedPolicies ?? 0) > 0
|
|
13
|
+
// A staged policy can stage a deletion, so the projected coverage is allowed
|
|
14
|
+
// to be lower than today's. Clamping it would hide exactly the case an
|
|
15
|
+
// operator most needs to see before promoting the staged set.
|
|
16
|
+
const coveredIfStaged = data.coveredWorkloadsIfStaged ?? data.coveredWorkloads
|
|
17
|
+
const stagedDelta = coveredIfStaged - data.coveredWorkloads
|
|
11
18
|
const percentage = data.totalWorkloads > 0
|
|
12
19
|
? Math.round((data.coveredWorkloads / data.totalWorkloads) * 100)
|
|
13
20
|
: 0
|
|
21
|
+
const percentageIfStaged = data.totalWorkloads > 0
|
|
22
|
+
? Math.round((coveredIfStaged / data.totalWorkloads) * 100)
|
|
23
|
+
: 0
|
|
24
|
+
const enforcedPercentage = hasStagedPolicies ? Math.min(percentage, percentageIfStaged) : percentage
|
|
25
|
+
// Gated on the same condition as the segment that draws it, so the three
|
|
26
|
+
// widths always sum to the full track even for a host that supplies a
|
|
27
|
+
// projection without any staged policies.
|
|
28
|
+
const stagedDeltaPercentage = hasStagedPolicies ? Math.abs(percentageIfStaged - percentage) : 0
|
|
14
29
|
const hasPolicies = data.totalPolicies > 0
|
|
15
30
|
const accentColor = !hasPolicies
|
|
16
31
|
? 'text-theme-text-tertiary'
|
|
@@ -48,28 +63,56 @@ export function NetworkPolicyCoverageCard({ data, onNavigate }: NetworkPolicyCov
|
|
|
48
63
|
<>
|
|
49
64
|
<div className="flex items-center gap-3 w-full">
|
|
50
65
|
<div className="flex-1 h-3 rounded-full overflow-hidden bg-theme-hover flex">
|
|
51
|
-
{
|
|
66
|
+
{enforcedPercentage > 0 && (
|
|
52
67
|
<div
|
|
53
68
|
className="h-full bg-green-500"
|
|
54
|
-
style={{ width: `${
|
|
69
|
+
style={{ width: `${enforcedPercentage}%` }}
|
|
55
70
|
/>
|
|
56
71
|
)}
|
|
57
|
-
{
|
|
72
|
+
{hasStagedPolicies && stagedDeltaPercentage > 0 && (
|
|
73
|
+
// The width belongs on the flex child; the tooltip wrapper
|
|
74
|
+
// inside it carries the hover target.
|
|
75
|
+
<div className="h-full" style={{ width: `${stagedDeltaPercentage}%` }}>
|
|
76
|
+
<Tooltip
|
|
77
|
+
content={stagedDelta > 0
|
|
78
|
+
? `${stagedDelta} more workloads covered if staged policies are applied`
|
|
79
|
+
: `${-stagedDelta} workloads lose coverage if staged policies are applied`}
|
|
80
|
+
wrapperClassName="!block h-full w-full"
|
|
81
|
+
>
|
|
82
|
+
<div
|
|
83
|
+
className={clsx('h-full w-full', stagedDelta > 0 ? 'text-yellow-500' : 'text-red-500')}
|
|
84
|
+
style={{
|
|
85
|
+
backgroundImage: 'repeating-linear-gradient(135deg, currentColor 0, currentColor 2px, transparent 2px, transparent 5px)',
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
</Tooltip>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
{100 - enforcedPercentage - stagedDeltaPercentage > 0 && (
|
|
58
92
|
<div
|
|
59
93
|
className="h-full bg-theme-hover"
|
|
60
|
-
style={{ width: `${100 -
|
|
94
|
+
style={{ width: `${100 - enforcedPercentage - stagedDeltaPercentage}%` }}
|
|
61
95
|
/>
|
|
62
96
|
)}
|
|
63
97
|
</div>
|
|
64
|
-
<
|
|
65
|
-
{percentage}
|
|
66
|
-
|
|
98
|
+
<div className="flex shrink-0 flex-col items-end tabular-nums">
|
|
99
|
+
<span className={clsx('text-sm font-semibold', accentColor)}>{percentage}%</span>
|
|
100
|
+
{hasStagedPolicies && (
|
|
101
|
+
<span className="text-[10px] text-theme-text-tertiary">({percentageIfStaged}% if staged applied)</span>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
67
104
|
</div>
|
|
68
105
|
|
|
69
106
|
<div className="grid grid-cols-1 gap-y-2 mt-4 w-full">
|
|
70
107
|
<StatRow label="Policies" value={data.totalPolicies} />
|
|
71
108
|
<StatRow label="Covered workloads" value={data.coveredWorkloads} total={data.totalWorkloads} />
|
|
109
|
+
{hasStagedPolicies && (
|
|
110
|
+
<StatRow label="Covered if staged" value={coveredIfStaged} total={data.totalWorkloads} />
|
|
111
|
+
)}
|
|
72
112
|
<StatRow label="Uncovered workloads" value={data.totalWorkloads - data.coveredWorkloads} warn />
|
|
113
|
+
{hasStagedPolicies && (
|
|
114
|
+
<StatRow label="Uncovered if staged" value={data.totalWorkloads - coveredIfStaged} warn />
|
|
115
|
+
)}
|
|
73
116
|
</div>
|
|
74
117
|
</>
|
|
75
118
|
)}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import type { Topology } from '../../types'
|
|
3
|
-
import type { DashboardTopologySummary } from '../../api/client'
|
|
4
3
|
import { Network, ArrowRight } from 'lucide-react'
|
|
5
4
|
import { clsx } from 'clsx'
|
|
6
5
|
|
|
7
6
|
interface TopologyPreviewProps {
|
|
8
7
|
topology: Topology | null
|
|
9
|
-
|
|
8
|
+
namespaceSelected: boolean
|
|
10
9
|
onNavigate: () => void
|
|
11
10
|
}
|
|
12
11
|
|
|
@@ -133,9 +132,30 @@ const kindDotColors: Record<string, string> = {
|
|
|
133
132
|
ReplicaSet: 'bg-green-400', HPA: 'bg-pink-500', PVC: 'bg-cyan-400',
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
export function TopologyPreview({ topology,
|
|
135
|
+
export function TopologyPreview({ topology, namespaceSelected, onNavigate }: TopologyPreviewProps) {
|
|
137
136
|
const stats = useTopologyStats(topology)
|
|
138
137
|
|
|
138
|
+
// A large cluster with no namespace filter gets an empty graph carrying this
|
|
139
|
+
// flag instead of a build. There is no graph to preview and there never will
|
|
140
|
+
// be until the user filters, so the card says that rather than counting a
|
|
141
|
+
// refusal as zero.
|
|
142
|
+
//
|
|
143
|
+
// With a pick already active the same flag means something else entirely: SSE
|
|
144
|
+
// only learns to filter server-side after a flagged frame arrives, so a
|
|
145
|
+
// cluster-wide refusal still reaches a viewer whose namespace is on its way
|
|
146
|
+
// to the server. That resolves on its own, and asking for what the viewer
|
|
147
|
+
// already chose is the wrong thing to say while it does.
|
|
148
|
+
const needsNamespaceFilter = topology?.requiresNamespaceFilter === true && !namespaceSelected
|
|
149
|
+
|
|
150
|
+
// The graph this card draws is the only source for its caption. Node count is
|
|
151
|
+
// not the test for whether a frame counts — the stream holds topology at null
|
|
152
|
+
// until one lands and resets it there on reconnect and context switch, so a
|
|
153
|
+
// delivered graph with no nodes is an empty scope and an honest zero.
|
|
154
|
+
const counts = useMemo(() => {
|
|
155
|
+
if (!topology || topology.requiresNamespaceFilter) return null
|
|
156
|
+
return { nodeCount: topology.nodes.length, edgeCount: topology.edges.length }
|
|
157
|
+
}, [topology])
|
|
158
|
+
|
|
139
159
|
return (
|
|
140
160
|
<button
|
|
141
161
|
onClick={onNavigate}
|
|
@@ -147,13 +167,27 @@ export function TopologyPreview({ topology, summary, onNavigate }: TopologyPrevi
|
|
|
147
167
|
<Network className="w-4 h-4 text-theme-text-tertiary" />
|
|
148
168
|
<span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Topology</span>
|
|
149
169
|
</div>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
170
|
+
{counts ? (
|
|
171
|
+
<span className="text-[11px] text-theme-text-tertiary">
|
|
172
|
+
{counts.nodeCount} resources · {counts.edgeCount} conn
|
|
173
|
+
</span>
|
|
174
|
+
) : needsNamespaceFilter ? null : (
|
|
175
|
+
<span className="h-3 w-28 rounded bg-theme-text-tertiary/20 animate-pulse" />
|
|
176
|
+
)}
|
|
153
177
|
</div>
|
|
154
178
|
|
|
155
179
|
{/* Stats (left) + Schematic (right) */}
|
|
156
180
|
<div className="flex-1 flex items-stretch min-h-0 px-3 py-1.5 gap-2">
|
|
181
|
+
{needsNamespaceFilter ? (
|
|
182
|
+
<div className="flex-1 flex items-center justify-center px-4">
|
|
183
|
+
<p className="text-[11px] leading-relaxed text-theme-text-tertiary text-center">
|
|
184
|
+
This cluster is too large to graph every namespace.
|
|
185
|
+
<br />
|
|
186
|
+
<span className="text-theme-text-secondary font-medium">Select a namespace</span> to view the topology.
|
|
187
|
+
</p>
|
|
188
|
+
</div>
|
|
189
|
+
) : (
|
|
190
|
+
<>
|
|
157
191
|
{/* Left: compact stats */}
|
|
158
192
|
<div className="flex flex-col justify-center gap-0.5 min-w-0 w-[105px] shrink-0">
|
|
159
193
|
{stats ? (
|
|
@@ -187,16 +221,26 @@ export function TopologyPreview({ topology, summary, onNavigate }: TopologyPrevi
|
|
|
187
221
|
)}
|
|
188
222
|
</>
|
|
189
223
|
) : (
|
|
190
|
-
//
|
|
224
|
+
// No graph yet, or one with nothing in scope. Until a frame lands
|
|
225
|
+
// there is no honest number here — a zero would read as an empty
|
|
226
|
+
// cluster, so pulse instead.
|
|
191
227
|
<div className="flex flex-col gap-0.5">
|
|
192
228
|
<div className="flex items-center gap-1.5 text-[10px] leading-tight">
|
|
193
|
-
<span className=
|
|
194
|
-
|
|
229
|
+
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', counts ? 'bg-blue-400' : 'bg-theme-text-tertiary/30 animate-pulse')} />
|
|
230
|
+
{counts ? (
|
|
231
|
+
<span className="text-theme-text-primary font-medium w-5 text-right tabular-nums">{counts.nodeCount}</span>
|
|
232
|
+
) : (
|
|
233
|
+
<span className="h-3 w-5 rounded bg-theme-text-tertiary/20 animate-pulse" />
|
|
234
|
+
)}
|
|
195
235
|
<span className="text-theme-text-tertiary">resources</span>
|
|
196
236
|
</div>
|
|
197
237
|
<div className="flex items-center gap-1.5 text-[10px] leading-tight">
|
|
198
|
-
<span className=
|
|
199
|
-
|
|
238
|
+
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', counts ? 'bg-theme-text-tertiary' : 'bg-theme-text-tertiary/30 animate-pulse')} />
|
|
239
|
+
{counts ? (
|
|
240
|
+
<span className="text-theme-text-primary font-medium w-5 text-right tabular-nums">{counts.edgeCount}</span>
|
|
241
|
+
) : (
|
|
242
|
+
<span className="h-3 w-5 rounded bg-theme-text-tertiary/10 animate-pulse" />
|
|
243
|
+
)}
|
|
200
244
|
<span className="text-theme-text-tertiary">connections</span>
|
|
201
245
|
</div>
|
|
202
246
|
</div>
|
|
@@ -207,6 +251,8 @@ export function TopologyPreview({ topology, summary, onNavigate }: TopologyPrevi
|
|
|
207
251
|
<div className="flex-1 flex items-center min-w-0">
|
|
208
252
|
<TopologySchematic />
|
|
209
253
|
</div>
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
210
256
|
</div>
|
|
211
257
|
|
|
212
258
|
<div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-theme-text-secondary group-hover:text-theme-text-primary transition-colors">
|
|
@@ -52,13 +52,13 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
name: 'get_resource',
|
|
55
|
-
desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics data.',
|
|
55
|
+
desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics/change/revision data.',
|
|
56
56
|
params: [
|
|
57
57
|
{ arg: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service' },
|
|
58
58
|
{ arg: 'name', required: true, desc: 'resource name' },
|
|
59
59
|
{ arg: 'namespace', desc: 'omit for cluster-scoped kinds (Node, ClusterRole, IngressClass, etc.)' },
|
|
60
60
|
{ arg: 'group', desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev for Knative Service vs core Service)' },
|
|
61
|
-
{ arg: 'include', desc: 'events, metrics' },
|
|
61
|
+
{ arg: 'include', desc: 'events, metrics, changes, revisions (rollback targets for Deployment/StatefulSet/DaemonSet/Rollout)' },
|
|
62
62
|
{ arg: 'context', desc: 'resourceContext tier: basic (default) or none' },
|
|
63
63
|
],
|
|
64
64
|
},
|
|
@@ -147,6 +147,18 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
147
147
|
{ arg: 'limit', desc: 'max findings (default 30, max 100)' },
|
|
148
148
|
],
|
|
149
149
|
},
|
|
150
|
+
{
|
|
151
|
+
name: 'get_cluster_upgrade_readiness',
|
|
152
|
+
desc: 'Upgrade impact analysis for a target Kubernetes minor: the evidenced check catalog (version skew, removed/deprecated APIs, node runtime, drain feasibility, webhook readiness) with per-check coverage and caveats, expandable into findings with evidence and remediation. The first call runs a live scan; results are briefly cached per caller.',
|
|
153
|
+
params: [
|
|
154
|
+
{ arg: 'target', desc: 'target Kubernetes minor (default: next minor above current)' },
|
|
155
|
+
{ arg: 'check', desc: 'check id to expand into findings' },
|
|
156
|
+
{ arg: 'level', desc: 'filter expanded findings: blocker, warning, or review' },
|
|
157
|
+
{ arg: 'offset', desc: 'page through findings beyond the per-call cap' },
|
|
158
|
+
{ arg: 'scan_id', desc: 'binds paging to one scan snapshot (required with offset)' },
|
|
159
|
+
{ arg: 'refresh', desc: 'bypass the cached scan after changing something' },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
150
162
|
{
|
|
151
163
|
name: 'list_helm_releases',
|
|
152
164
|
desc: 'All Helm releases with status, resource health, storage namespace, Flux ownership, current lastOperation, and capped operation trails for failed upgrades, rollbacks, or stuck pending operations.',
|
|
@@ -260,14 +272,24 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
260
272
|
{
|
|
261
273
|
name: 'manage_workload',
|
|
262
274
|
write: true,
|
|
263
|
-
desc: 'Operate on a workload: restart triggers a rolling restart, scale changes the replica count, rollback reverts to a previous revision.',
|
|
275
|
+
desc: 'Operate on a workload: restart triggers a rolling restart, scale changes the replica count, rollback reverts to a previous revision. Rollout rollback re-runs every canary step — pair it with manage_rollout promote-full, or abort instead.',
|
|
264
276
|
params: [
|
|
265
277
|
{ arg: 'action', required: true, desc: 'restart, scale, or rollback' },
|
|
266
|
-
{ arg: 'kind', required: true, desc: 'deployment, statefulset, or
|
|
278
|
+
{ arg: 'kind', required: true, desc: 'deployment, statefulset, daemonset, or rollout' },
|
|
267
279
|
{ arg: 'namespace', required: true, desc: 'workload namespace' },
|
|
268
280
|
{ arg: 'name', required: true, desc: 'workload name' },
|
|
269
281
|
{ arg: 'replicas', desc: 'target replica count (for scale)' },
|
|
270
|
-
{ arg: 'revision', desc: 'target revision (for rollback)' },
|
|
282
|
+
{ arg: 'revision', desc: 'target revision (for rollback); list them with get_resource include=revisions' },
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
name: 'manage_rollout',
|
|
287
|
+
write: true,
|
|
288
|
+
desc: 'Control an Argo Rollout progressive delivery: abort reverts traffic to the last stable version immediately, retry clears an abort, promote advances one step, promote-full skips all remaining steps/pauses/analysis, skip-step advances exactly one canary step. A Rollout paused on an inconclusive analysis names its AnalysisRun in status — read that first.',
|
|
289
|
+
params: [
|
|
290
|
+
{ arg: 'action', required: true, desc: 'abort, retry, promote, promote-full, or skip-step' },
|
|
291
|
+
{ arg: 'namespace', required: true, desc: 'rollout namespace' },
|
|
292
|
+
{ arg: 'name', required: true, desc: 'rollout name' },
|
|
271
293
|
],
|
|
272
294
|
},
|
|
273
295
|
{
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
GitBranch,
|
|
11
11
|
Boxes,
|
|
12
12
|
Activity,
|
|
13
|
-
|
|
13
|
+
Coins,
|
|
14
14
|
Gauge,
|
|
15
15
|
ShieldCheck,
|
|
16
16
|
Settings,
|
|
@@ -80,7 +80,7 @@ const NAV_ITEMS: NavItemDef[] = [
|
|
|
80
80
|
{ view: "gitops", icon: GitBranch, label: "GitOps" },
|
|
81
81
|
{ view: "checks", icon: ShieldCheck, label: "Checks" },
|
|
82
82
|
{ view: "capacity", icon: Gauge, label: "Capacity" },
|
|
83
|
-
{ view: "cost", icon:
|
|
83
|
+
{ view: "cost", icon: Coins, label: "Cost" },
|
|
84
84
|
];
|
|
85
85
|
|
|
86
86
|
interface PrimaryNavRailProps {
|