@skyhook-io/k8s-ui 1.17.0 → 1.18.0
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/README.md +41 -0
- package/package.json +6 -6
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
- package/src/components/gitops/tree/tree-helpers.ts +12 -2
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +44 -6
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +8 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +12 -1
- package/src/components/issues/issues.test.ts +75 -1
- package/src/components/issues/severity.ts +30 -2
- package/src/components/issues/types.ts +55 -4
- package/src/components/logs/LogCore.theme.test.tsx +206 -3
- package/src/components/logs/LogCore.tsx +180 -53
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +369 -0
- package/src/components/logs/LogsViewer.tsx +91 -13
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +23 -31
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/logs/useLogStream.ts +17 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +392 -181
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -40
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -20
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +96 -7
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +14 -7
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +3 -71
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/Toast.tsx +15 -4
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- package/src/components/ui/index.ts +4 -2
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +193 -71
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +11 -2
- package/src/types/core.ts +53 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +20 -2
- package/src/types/gitops-tree.ts +12 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -200,6 +200,32 @@ export function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
// Default dimensions for unknown CRD kinds
|
|
203
|
+
// A domain-mapped Knative Service shows its custom domain as the subtitle; the
|
|
204
|
+
// tooltip lists every mapped domain, then the Service's own URL when it is
|
|
205
|
+
// public, and its cluster-internal address.
|
|
206
|
+
export function knativeDomainsTooltipLines(nodeData: Record<string, unknown>): { text: string; secondary?: boolean }[] {
|
|
207
|
+
const mapped = (nodeData.mappedDomains as string[] | undefined) || [];
|
|
208
|
+
if (mapped.length === 0) return [];
|
|
209
|
+
const url = nodeData.url as string | undefined;
|
|
210
|
+
const internalUrl = nodeData.internalUrl as string | undefined;
|
|
211
|
+
const lines: { text: string; secondary?: boolean }[] = mapped.map((domain) => ({ text: domain }));
|
|
212
|
+
if (url && url !== internalUrl) lines.push({ text: `Service URL: ${url}`, secondary: true });
|
|
213
|
+
if (internalUrl) lines.push({ text: `Internal: ${internalUrl}`, secondary: true });
|
|
214
|
+
return lines;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function knativeDomainsTooltip(nodeData: Record<string, unknown>): React.ReactNode | null {
|
|
218
|
+
const lines = knativeDomainsTooltipLines(nodeData);
|
|
219
|
+
if (lines.length === 0) return null;
|
|
220
|
+
return (
|
|
221
|
+
<div className="space-y-0.5">
|
|
222
|
+
{lines.map((line) => (
|
|
223
|
+
<div key={line.text} className={line.secondary ? 'text-theme-text-tertiary' : undefined}>{line.text}</div>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
203
229
|
export const DEFAULT_NODE_DIMENSIONS = { width: 260, height: 84 };
|
|
204
230
|
|
|
205
231
|
const NODE_NAME_LENGTH_LIMITS: Partial<Record<NodeKind, number>> = {
|
|
@@ -218,6 +244,7 @@ export const NODE_DIMENSIONS: Record<
|
|
|
218
244
|
HTTPRoute: { width: 300, height: 84 },
|
|
219
245
|
GRPCRoute: { width: 300, height: 84 },
|
|
220
246
|
TCPRoute: { width: 300, height: 84 },
|
|
247
|
+
UDPRoute: { width: 300, height: 84 },
|
|
221
248
|
TLSRoute: { width: 300, height: 84 },
|
|
222
249
|
Service: { width: 260, height: 84 },
|
|
223
250
|
Deployment: { width: 280, height: 84 },
|
|
@@ -440,7 +467,8 @@ export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>):
|
|
|
440
467
|
case "HTTPRoute":
|
|
441
468
|
case "GRPCRoute":
|
|
442
469
|
case "TCPRoute":
|
|
443
|
-
case "TLSRoute":
|
|
470
|
+
case "TLSRoute":
|
|
471
|
+
case "UDPRoute": {
|
|
444
472
|
const hostnames = nodeData.hostnames as string[];
|
|
445
473
|
const rulesCount = (nodeData.rulesCount as number) || 0;
|
|
446
474
|
const host = hostnames?.length ? hostnames[0] : "";
|
|
@@ -488,6 +516,10 @@ export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>):
|
|
|
488
516
|
return `${count} pods (${healthy} healthy)`;
|
|
489
517
|
}
|
|
490
518
|
case "KnativeService": {
|
|
519
|
+
const mapped = (nodeData.mappedDomains as string[] | undefined) || [];
|
|
520
|
+
if (mapped.length > 0) {
|
|
521
|
+
return mapped.length > 1 ? `${mapped[0]} +${mapped.length - 1}` : mapped[0];
|
|
522
|
+
}
|
|
491
523
|
const url = nodeData.url as string;
|
|
492
524
|
if (url) {
|
|
493
525
|
// Show just the hostname from the URL for compactness
|
|
@@ -558,7 +590,10 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
558
590
|
const subtitle = getSubtitle(kind, nodeData)
|
|
559
591
|
const portsTooltip = kind === 'Service'
|
|
560
592
|
? servicePortsTooltip((nodeData.ports as ServicePortEntry[] | undefined) || [])
|
|
561
|
-
:
|
|
593
|
+
: kind === 'KnativeService'
|
|
594
|
+
? knativeDomainsTooltip(nodeData)
|
|
595
|
+
: null
|
|
596
|
+
const clusterLocal = kind === 'KnativeService' && nodeData.visibility === 'cluster-local'
|
|
562
597
|
const isInternet = kind === 'Internet'
|
|
563
598
|
const isPodGroup = kind === 'PodGroup'
|
|
564
599
|
const isSmallNode = kind === 'ConfigMap' || kind === 'Secret' || kind === 'ServiceAccount' || kind === 'SealedSecret' || kind === 'ServiceMonitor' || kind === 'PodMonitor' || kind === 'HorizontalPodAutoscaler'
|
|
@@ -664,6 +699,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
664
699
|
</span>
|
|
665
700
|
</Tooltip>
|
|
666
701
|
)}
|
|
702
|
+
{clusterLocal && (
|
|
703
|
+
<Tooltip content="Reachable only inside the cluster, so it has no Internet edge" position="right">
|
|
704
|
+
<Badge tone="note" size="sm" className="!text-[9px] !px-1 !py-0.5 normal-case tracking-normal cursor-help">
|
|
705
|
+
Cluster-local
|
|
706
|
+
</Badge>
|
|
707
|
+
</Tooltip>
|
|
708
|
+
)}
|
|
667
709
|
{trafficRole && (
|
|
668
710
|
<Badge
|
|
669
711
|
tone={trafficRole === 'canary' || trafficRole === 'preview' ? 'accent1' : 'accent2'}
|
|
@@ -33,6 +33,7 @@ const RESOURCE_KINDS: {
|
|
|
33
33
|
{ kind: 'GRPCRoute', label: 'GRPCRoute', icon: getTopologyIcon('GRPCRoute'), color: 'text-purple-300', category: 'networking' },
|
|
34
34
|
{ kind: 'TCPRoute', label: 'TCPRoute', icon: getTopologyIcon('TCPRoute'), color: 'text-purple-300', category: 'networking' },
|
|
35
35
|
{ kind: 'TLSRoute', label: 'TLSRoute', icon: getTopologyIcon('TLSRoute'), color: 'text-purple-300', category: 'networking' },
|
|
36
|
+
{ kind: 'UDPRoute', label: 'UDPRoute', icon: getTopologyIcon('UDPRoute'), color: 'text-purple-300', category: 'networking' },
|
|
36
37
|
{ kind: 'Service', label: 'Service', icon: getTopologyIcon('Service'), color: 'text-blue-400', category: 'networking' },
|
|
37
38
|
{ kind: 'CalicoNetworkPolicy', label: 'Calico NetworkPolicy', icon: getTopologyIcon('CalicoNetworkPolicy'), color: 'text-teal-400', category: 'custom' },
|
|
38
39
|
{ kind: 'CalicoGlobalNetworkPolicy', label: 'Calico GlobalNetworkPolicy', icon: getTopologyIcon('CalicoGlobalNetworkPolicy'), color: 'text-teal-400', category: 'custom' },
|
|
@@ -32,6 +32,7 @@ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
|
32
32
|
|
|
33
33
|
import { K8sResourceNode } from './K8sResourceNode'
|
|
34
34
|
import { GroupNode } from './GroupNode'
|
|
35
|
+
import { splitInternetByGroup } from './internet-groups'
|
|
35
36
|
import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
|
|
36
37
|
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
37
38
|
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, isGroupEffectivelyCollapsed, type GroupDisplayLevel } from './layout'
|
|
@@ -548,75 +549,6 @@ export function TopologyGraph({
|
|
|
548
549
|
return { nodes: newNodes, edges: newEdges }
|
|
549
550
|
}, [])
|
|
550
551
|
|
|
551
|
-
// Transform to per-group Internet nodes in traffic view with grouping
|
|
552
|
-
const createPerGroupInternetNodes = useCallback((
|
|
553
|
-
nodes: TopologyNode[],
|
|
554
|
-
edges: TopologyEdge[],
|
|
555
|
-
groupMode: GroupingMode
|
|
556
|
-
): { nodes: TopologyNode[]; edges: TopologyEdge[] } => {
|
|
557
|
-
if (groupMode === 'none') {
|
|
558
|
-
return { nodes, edges }
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Find the single Internet node
|
|
562
|
-
const internetNode = nodes.find(n => n.kind === 'Internet')
|
|
563
|
-
if (!internetNode) {
|
|
564
|
-
return { nodes, edges }
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Find all ingresses/gateways and group them
|
|
568
|
-
const ingresses = nodes.filter(n => n.kind === 'Ingress' || n.kind === 'Gateway')
|
|
569
|
-
const groupsWithIngresses = new Map<string, TopologyNode[]>()
|
|
570
|
-
|
|
571
|
-
for (const ingress of ingresses) {
|
|
572
|
-
const groupKey = getGroupKey(ingress, groupMode)
|
|
573
|
-
if (groupKey) {
|
|
574
|
-
if (!groupsWithIngresses.has(groupKey)) {
|
|
575
|
-
groupsWithIngresses.set(groupKey, [])
|
|
576
|
-
}
|
|
577
|
-
groupsWithIngresses.get(groupKey)!.push(ingress)
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// If no groups with ingresses, keep original
|
|
582
|
-
if (groupsWithIngresses.size === 0) {
|
|
583
|
-
return { nodes, edges }
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// Remove original Internet node and its edges
|
|
587
|
-
const newNodes = nodes.filter(n => n.id !== internetNode.id)
|
|
588
|
-
const newEdges = edges.filter(e => e.source !== internetNode.id)
|
|
589
|
-
|
|
590
|
-
// Create per-group Internet nodes
|
|
591
|
-
for (const [groupKey, groupIngresses] of groupsWithIngresses) {
|
|
592
|
-
const internetId = `internet-${groupMode}-${groupKey}`
|
|
593
|
-
|
|
594
|
-
// Add Internet node for this group with group metadata
|
|
595
|
-
newNodes.push({
|
|
596
|
-
id: internetId,
|
|
597
|
-
kind: 'Internet',
|
|
598
|
-
name: 'Internet',
|
|
599
|
-
status: 'healthy',
|
|
600
|
-
data: {
|
|
601
|
-
// Add group metadata so it gets grouped with its ingresses
|
|
602
|
-
namespace: groupMode === 'namespace' ? groupKey : groupIngresses[0]?.data?.namespace,
|
|
603
|
-
labels: groupMode === 'app' ? { 'app.kubernetes.io/name': groupKey } : {},
|
|
604
|
-
},
|
|
605
|
-
})
|
|
606
|
-
|
|
607
|
-
// Add edges from this Internet node to its ingresses
|
|
608
|
-
for (const ingress of groupIngresses) {
|
|
609
|
-
newEdges.push({
|
|
610
|
-
id: `${internetId}-to-${ingress.id}`,
|
|
611
|
-
source: internetId,
|
|
612
|
-
target: ingress.id,
|
|
613
|
-
type: 'routes-to',
|
|
614
|
-
})
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
return { nodes: newNodes, edges: newEdges }
|
|
619
|
-
}, [])
|
|
620
552
|
|
|
621
553
|
// Prepare topology data with expanded pod groups
|
|
622
554
|
const { workingNodes, workingEdges } = useMemo(() => {
|
|
@@ -636,13 +568,13 @@ export function TopologyGraph({
|
|
|
636
568
|
|
|
637
569
|
// In traffic view with grouping, create per-group Internet nodes
|
|
638
570
|
if (isTrafficView && groupingMode !== 'none') {
|
|
639
|
-
const result =
|
|
571
|
+
const result = splitInternetByGroup(nodes, edges, groupingMode)
|
|
640
572
|
nodes = result.nodes
|
|
641
573
|
edges = result.edges
|
|
642
574
|
}
|
|
643
575
|
|
|
644
576
|
return { workingNodes: nodes, workingEdges: edges }
|
|
645
|
-
}, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode
|
|
577
|
+
}, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode])
|
|
646
578
|
|
|
647
579
|
// Handle card click in card-grid view — find the topology node and open drawer
|
|
648
580
|
const handleCardClick = useCallback((nodeId: string) => {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { TopologyEdge, TopologyNode } from '../../types'
|
|
3
|
+
import { splitInternetByGroup } from './internet-groups'
|
|
4
|
+
|
|
5
|
+
const node = (id: string, kind: string, namespace?: string): TopologyNode =>
|
|
6
|
+
({ id, kind, name: id, status: 'healthy', data: namespace ? { namespace } : {} }) as TopologyNode
|
|
7
|
+
const fromInternet = (target: string): TopologyEdge => ({ id: `internet-to-${target}`, source: 'internet', target, type: 'routes-to' })
|
|
8
|
+
|
|
9
|
+
describe('splitInternetByGroup', () => {
|
|
10
|
+
it('keeps every entrypoint connected, not only Ingresses and Gateways', () => {
|
|
11
|
+
const nodes = [
|
|
12
|
+
node('internet', 'Internet'),
|
|
13
|
+
node('ingress/shop/web', 'Ingress', 'shop'),
|
|
14
|
+
node('knativeservice/shop/api', 'KnativeService', 'shop'),
|
|
15
|
+
node('ingressroute/edge/dash', 'IngressRoute', 'edge'),
|
|
16
|
+
]
|
|
17
|
+
const edges = nodes.slice(1).map(n => fromInternet(n.id))
|
|
18
|
+
const out = splitInternetByGroup(nodes, edges, 'namespace')
|
|
19
|
+
|
|
20
|
+
const reached = out.edges.filter(e => out.nodes.find(n => n.id === e.source)?.kind === 'Internet').map(e => `${e.source} -> ${e.target}`)
|
|
21
|
+
expect(reached.sort()).toEqual([
|
|
22
|
+
'internet-namespace-edge -> ingressroute/edge/dash',
|
|
23
|
+
'internet-namespace-shop -> ingress/shop/web',
|
|
24
|
+
'internet-namespace-shop -> knativeservice/shop/api',
|
|
25
|
+
])
|
|
26
|
+
expect(out.nodes.some(n => n.id === 'internet')).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('leaves an ungrouped entrypoint on the original Internet node', () => {
|
|
30
|
+
const nodes = [node('internet', 'Internet'), node('ingress/shop/web', 'Ingress', 'shop'), node('gateway//shared', 'Gateway')]
|
|
31
|
+
const out = splitInternetByGroup(nodes, nodes.slice(1).map(n => fromInternet(n.id)), 'namespace')
|
|
32
|
+
expect(out.edges.some(e => e.source === 'internet' && e.target === 'gateway//shared')).toBe(true)
|
|
33
|
+
expect(out.nodes.some(n => n.id === 'internet')).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { GroupingMode, TopologyEdge, TopologyNode } from '../../types'
|
|
2
|
+
import { getGroupKey } from './layout'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Splits the traffic view's single Internet node into one per group, so each
|
|
6
|
+
* namespace or app shows its own entry from outside. Every original Internet
|
|
7
|
+
* edge is kept, whatever kind its entrypoint is (Ingress, Gateway, Istio
|
|
8
|
+
* Gateway, Knative Service, Traefik route, Contour HTTPProxy); an entrypoint
|
|
9
|
+
* with no group stays connected to the original Internet node.
|
|
10
|
+
*/
|
|
11
|
+
export function splitInternetByGroup(
|
|
12
|
+
nodes: TopologyNode[],
|
|
13
|
+
edges: TopologyEdge[],
|
|
14
|
+
groupMode: GroupingMode,
|
|
15
|
+
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
|
|
16
|
+
if (groupMode === 'none') return { nodes, edges }
|
|
17
|
+
const internetNode = nodes.find(n => n.kind === 'Internet')
|
|
18
|
+
if (!internetNode) return { nodes, edges }
|
|
19
|
+
|
|
20
|
+
const nodeById = new Map(nodes.map(n => [n.id, n]))
|
|
21
|
+
const groups = new Map<string, { entry: TopologyNode; edges: TopologyEdge[] }>()
|
|
22
|
+
const regrouped = new Set<TopologyEdge>()
|
|
23
|
+
for (const edge of edges) {
|
|
24
|
+
if (edge.source !== internetNode.id) continue
|
|
25
|
+
const target = nodeById.get(edge.target)
|
|
26
|
+
const groupKey = target ? getGroupKey(target, groupMode) : null
|
|
27
|
+
if (!target || !groupKey) continue
|
|
28
|
+
if (!groups.has(groupKey)) groups.set(groupKey, { entry: target, edges: [] })
|
|
29
|
+
groups.get(groupKey)!.edges.push(edge)
|
|
30
|
+
regrouped.add(edge)
|
|
31
|
+
}
|
|
32
|
+
if (groups.size === 0) return { nodes, edges }
|
|
33
|
+
|
|
34
|
+
const keepOriginal = edges.some(e => e.source === internetNode.id && !regrouped.has(e))
|
|
35
|
+
const newNodes = keepOriginal ? [...nodes] : nodes.filter(n => n.id !== internetNode.id)
|
|
36
|
+
const newEdges = edges.filter(e => !regrouped.has(e))
|
|
37
|
+
for (const [groupKey, group] of groups) {
|
|
38
|
+
const internetId = `internet-${groupMode}-${groupKey}`
|
|
39
|
+
newNodes.push({
|
|
40
|
+
id: internetId,
|
|
41
|
+
kind: 'Internet',
|
|
42
|
+
name: 'Internet',
|
|
43
|
+
status: 'healthy',
|
|
44
|
+
data: {
|
|
45
|
+
// Group metadata so the node lands inside its entrypoints' group.
|
|
46
|
+
namespace: groupMode === 'namespace' ? groupKey : group.entry.data?.namespace,
|
|
47
|
+
labels: groupMode === 'app' ? { 'app.kubernetes.io/name': groupKey } : {},
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
for (const edge of group.edges) {
|
|
51
|
+
newEdges.push({ ...edge, id: `${internetId}-to-${edge.target}`, source: internetId })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { nodes: newNodes, edges: newEdges }
|
|
55
|
+
}
|
|
@@ -864,7 +864,7 @@ function computeWorkloadCards(
|
|
|
864
864
|
const KIND_PRIORITY: Record<string, number> = {
|
|
865
865
|
'Deployment': 1, 'Rollout': 1, 'StatefulSet': 2, 'DaemonSet': 3,
|
|
866
866
|
'CronJob': 4, 'CronWorkflow': 4, 'Job': 5, 'Workflow': 5, 'Service': 6, 'Gateway': 7,
|
|
867
|
-
'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'Ingress': 7,
|
|
867
|
+
'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'UDPRoute': 6, 'Ingress': 7,
|
|
868
868
|
'CalicoNetworkPolicy': 10, 'CalicoGlobalNetworkPolicy': 10,
|
|
869
869
|
'CalicoStagedNetworkPolicy': 10, 'CalicoStagedGlobalNetworkPolicy': 10,
|
|
870
870
|
'CalicoStagedKubernetesNetworkPolicy': 10,
|
|
@@ -85,6 +85,7 @@ const KIND: Record<string, string> = {
|
|
|
85
85
|
HTTPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
86
86
|
GRPCRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
87
87
|
TCPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
88
|
+
UDPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
88
89
|
TLSRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
89
90
|
|
|
90
91
|
// Config
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { toneTextClass } from './status-tone'
|
|
4
|
+
import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
|
|
5
|
+
|
|
6
|
+
/** A folded section's one-line summary, and whether it opens on its own. */
|
|
7
|
+
export interface FoldSummary {
|
|
8
|
+
text: string
|
|
9
|
+
/** Something in the section needs a look: it opens itself. */
|
|
10
|
+
attention: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SectionHeading({ children, hint }: { children: ReactNode; hint?: ReactNode }) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="mb-2 mt-5 flex items-baseline gap-2 first:mt-0">
|
|
16
|
+
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{children}</h3>
|
|
17
|
+
{hint && <span className="text-[11px] text-theme-text-tertiary">{hint}</span>}
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A section folded to one summary line. It opens itself when `attention`
|
|
24
|
+
* turns true (data arriving after the first render included), and stays as
|
|
25
|
+
* the reader left it otherwise.
|
|
26
|
+
*/
|
|
27
|
+
export function FoldSection({
|
|
28
|
+
title,
|
|
29
|
+
hint,
|
|
30
|
+
summary,
|
|
31
|
+
attention,
|
|
32
|
+
children,
|
|
33
|
+
}: {
|
|
34
|
+
title: ReactNode
|
|
35
|
+
hint?: ReactNode
|
|
36
|
+
summary: ReactNode
|
|
37
|
+
attention: boolean
|
|
38
|
+
children: ReactNode
|
|
39
|
+
}) {
|
|
40
|
+
const [open, setOpen] = useState(attention)
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (attention) setOpen(true)
|
|
43
|
+
}, [attention])
|
|
44
|
+
const d = useDisclosure(open)
|
|
45
|
+
return (
|
|
46
|
+
<div className="mt-5 first:mt-0">
|
|
47
|
+
<h3>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
{...d.buttonProps}
|
|
51
|
+
onClick={() => setOpen((v) => !v)}
|
|
52
|
+
className="flex w-full items-start gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
53
|
+
>
|
|
54
|
+
<CollapseChevron open={open} className="mt-1 h-3 w-3 shrink-0 text-theme-text-tertiary" />
|
|
55
|
+
{/* Wrapped parts line up under the title, not under the chevron. */}
|
|
56
|
+
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
57
|
+
<span className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{title}</span>
|
|
58
|
+
{hint && <span className="text-[11px] text-theme-text-tertiary">{hint}</span>}
|
|
59
|
+
{!open && <span className={clsx('min-w-0 text-sm', attention ? toneTextClass('degraded') : 'text-theme-text-secondary')}>{summary}</span>}
|
|
60
|
+
</span>
|
|
61
|
+
</button>
|
|
62
|
+
</h3>
|
|
63
|
+
<Collapse open={open} id={d.panelId}>
|
|
64
|
+
<div className="pt-2">{children}</div>
|
|
65
|
+
</Collapse>
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
|
|
6
|
+
|
|
7
|
+
let root: Root
|
|
8
|
+
const base = { open: true, onClose: vi.fn(), onConfirm: vi.fn(), resourceName: 'web', resourceKind: 'Deployment', namespaceName: 'demo', isLoading: false }
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
11
|
+
const host = document.createElement('div')
|
|
12
|
+
document.body.appendChild(host)
|
|
13
|
+
root = createRoot(host)
|
|
14
|
+
})
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await act(async () => root.unmount())
|
|
17
|
+
document.body.replaceChildren()
|
|
18
|
+
vi.clearAllMocks()
|
|
19
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('Deletion preview states', () => {
|
|
23
|
+
it('states the namespace-wide deletion consequence without relying on topology', async () => {
|
|
24
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} resourceKind="Namespace" resourceName="demo" namespaceName="" cascadeRootResolved={false} />))
|
|
25
|
+
expect(document.body.textContent).toContain('delete the namespace "demo" and the resources in it')
|
|
26
|
+
expect(document.body.textContent).not.toContain("not in Radar's topology")
|
|
27
|
+
})
|
|
28
|
+
it('shows one advisory statement for a resolved preview', async () => {
|
|
29
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'web-pod' }]} />))
|
|
30
|
+
const toggle = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('May affect'))!
|
|
31
|
+
await act(async () => toggle.click())
|
|
32
|
+
expect(document.body.textContent).toContain('web-pod')
|
|
33
|
+
expect(document.body.textContent?.match(/does not predict what will be deleted/g)).toHaveLength(1)
|
|
34
|
+
expect(document.body.textContent).not.toContain('Other resources may be affected')
|
|
35
|
+
})
|
|
36
|
+
it.each([[], undefined])('omits the advisory statement for an empty resolved preview (%j)', async (dependents) => {
|
|
37
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={dependents} />))
|
|
38
|
+
expect(document.body.textContent).not.toContain('May affect')
|
|
39
|
+
expect(document.body.textContent).not.toContain('does not predict')
|
|
40
|
+
expect(document.body.textContent).not.toContain('unavailable')
|
|
41
|
+
expect(document.body.textContent).not.toContain('Other resources may be affected')
|
|
42
|
+
})
|
|
43
|
+
it('distinguishes an observed unresolved root from a fetch failure', async () => {
|
|
44
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} />))
|
|
45
|
+
expect(document.body.textContent).toContain("This resource is not in Radar's topology")
|
|
46
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} cascadePreviewFailed cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'stale' }]} />))
|
|
47
|
+
expect(document.body.textContent).toContain('Related-resource preview unavailable.')
|
|
48
|
+
expect(document.body.textContent).not.toContain('This resource is not in')
|
|
49
|
+
expect(document.body.textContent).not.toContain('Other resources may be affected')
|
|
50
|
+
expect(document.body.textContent).not.toContain('May affect')
|
|
51
|
+
const confirm = [...document.querySelectorAll('button')].find(b => b.textContent === 'Delete')!
|
|
52
|
+
expect(confirm.disabled).toBe(false)
|
|
53
|
+
await act(async () => confirm.click())
|
|
54
|
+
expect(base.onConfirm).toHaveBeenCalledWith(false)
|
|
55
|
+
})
|
|
56
|
+
it('shows loading without presenting stale failure or success as a result', async () => {
|
|
57
|
+
await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeLoading cascadePreviewFailed cascadeRootResolved />))
|
|
58
|
+
expect(document.body.textContent).toContain('Checking related resources')
|
|
59
|
+
expect(document.body.textContent).not.toContain('unavailable')
|
|
60
|
+
expect(document.body.textContent).not.toContain('does not predict')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { Loader2 } from 'lucide-react'
|
|
3
3
|
import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
|
|
4
4
|
import { ConfirmDialog } from './ConfirmDialog'
|
|
5
5
|
import { pluralize } from '../../utils/pluralize'
|
|
@@ -22,6 +22,7 @@ interface ForceDeleteConfirmDialogProps {
|
|
|
22
22
|
cascadeDependents?: CascadeDependent[]
|
|
23
23
|
cascadeLoading?: boolean
|
|
24
24
|
cascadeRootResolved?: boolean
|
|
25
|
+
cascadePreviewFailed?: boolean
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function ForceDeleteConfirmDialog({
|
|
@@ -35,8 +36,10 @@ export function ForceDeleteConfirmDialog({
|
|
|
35
36
|
cascadeDependents,
|
|
36
37
|
cascadeLoading,
|
|
37
38
|
cascadeRootResolved,
|
|
39
|
+
cascadePreviewFailed,
|
|
38
40
|
}: ForceDeleteConfirmDialogProps) {
|
|
39
41
|
const [forceDelete, setForceDelete] = useState(false)
|
|
42
|
+
const isNamespace = resourceKind.toLowerCase() === 'namespace'
|
|
40
43
|
|
|
41
44
|
function handleClose() {
|
|
42
45
|
onClose()
|
|
@@ -54,7 +57,7 @@ export function ForceDeleteConfirmDialog({
|
|
|
54
57
|
onConfirm={handleConfirm}
|
|
55
58
|
title="Delete Resource"
|
|
56
59
|
message={`Are you sure you want to delete "${resourceName}"?`}
|
|
57
|
-
details={`This will permanently delete the ${resourceKind} "${resourceName}" from the "${namespaceName}" namespace.`}
|
|
60
|
+
details={isNamespace ? `This will permanently delete the namespace "${resourceName}" and the resources in it.` : `This will permanently delete the ${resourceKind} "${resourceName}" from the "${namespaceName}" namespace.`}
|
|
58
61
|
confirmLabel={forceDelete ? 'Force Delete' : 'Delete'}
|
|
59
62
|
variant="danger"
|
|
60
63
|
isLoading={isLoading}
|
|
@@ -63,21 +66,30 @@ export function ForceDeleteConfirmDialog({
|
|
|
63
66
|
{cascadeLoading && (
|
|
64
67
|
<div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
|
|
65
68
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
66
|
-
Checking
|
|
69
|
+
Checking related resources...
|
|
67
70
|
</div>
|
|
68
71
|
)}
|
|
69
72
|
|
|
70
|
-
{!cascadeLoading && cascadeDependents && cascadeDependents.length > 0 && (
|
|
73
|
+
{!cascadeLoading && !cascadePreviewFailed && cascadeDependents && cascadeDependents.length > 0 && (
|
|
71
74
|
<CascadeDependentsList dependents={cascadeDependents} />
|
|
72
75
|
)}
|
|
73
76
|
|
|
74
|
-
{!cascadeLoading &&
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
{!cascadeLoading && cascadePreviewFailed && (
|
|
78
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
79
|
+
Related-resource preview unavailable.
|
|
80
|
+
</p>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{!isNamespace && !cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === false && (
|
|
84
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
85
|
+
This resource is not in Radar's topology, so its related resources cannot be previewed.
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{!cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === true && cascadeDependents && cascadeDependents.length > 0 && (
|
|
90
|
+
<p className="text-xs text-theme-text-secondary">
|
|
91
|
+
This list shows visible related resources; it does not predict what will be deleted.
|
|
92
|
+
</p>
|
|
81
93
|
)}
|
|
82
94
|
|
|
83
95
|
<label className="flex items-center gap-2 text-sm text-theme-text-secondary">
|
|
@@ -120,7 +132,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
|
|
|
120
132
|
>
|
|
121
133
|
<CollapseChevron open={expanded} inheritColor className="w-3.5 h-3.5" />
|
|
122
134
|
<span>
|
|
123
|
-
|
|
135
|
+
May affect {pluralize(dependents.length, 'related resource')}
|
|
124
136
|
</span>
|
|
125
137
|
</button>
|
|
126
138
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import type { ResourceRef } from '../../types/core'
|
|
4
|
+
|
|
5
|
+
export type NavigateToRef = (ref: ResourceRef) => void
|
|
6
|
+
|
|
7
|
+
/** A reference to another object: a link when the host can navigate, plain text otherwise. */
|
|
8
|
+
export function RefLink({ refTo, onNavigate, children, mono }: { refTo: ResourceRef; onNavigate?: NavigateToRef; children?: ReactNode; mono?: boolean }) {
|
|
9
|
+
const label = children ?? refTo.name
|
|
10
|
+
if (!onNavigate) return <span className={clsx(mono && 'font-mono')}>{label}</span>
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
onClick={() => onNavigate(refTo)}
|
|
15
|
+
className={clsx('text-accent-text hover:underline text-left break-all', mono && 'font-mono')}
|
|
16
|
+
>
|
|
17
|
+
{label}
|
|
18
|
+
</button>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -56,14 +56,19 @@ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true
|
|
|
56
56
|
const onDown = (e: MouseEvent) => {
|
|
57
57
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
58
58
|
}
|
|
59
|
+
// Capture phase, consumed: Escape that closes the menu must not also run
|
|
60
|
+
// the page's own Escape (close the drawer, leave the full view).
|
|
59
61
|
const onKey = (e: KeyboardEvent) => {
|
|
60
|
-
if (e.key
|
|
62
|
+
if (e.key !== 'Escape') return
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
e.stopPropagation()
|
|
65
|
+
setOpen(false)
|
|
61
66
|
}
|
|
62
67
|
document.addEventListener('mousedown', onDown)
|
|
63
|
-
document.addEventListener('keydown', onKey)
|
|
68
|
+
document.addEventListener('keydown', onKey, true)
|
|
64
69
|
return () => {
|
|
65
70
|
document.removeEventListener('mousedown', onDown)
|
|
66
|
-
document.removeEventListener('keydown', onKey)
|
|
71
|
+
document.removeEventListener('keydown', onKey, true)
|
|
67
72
|
}
|
|
68
73
|
}, [open])
|
|
69
74
|
|
|
@@ -36,6 +36,7 @@ class ToastManager {
|
|
|
36
36
|
private static instance: ToastManager
|
|
37
37
|
private showErrorFn: ((message: string, detail?: string) => void) | null = null
|
|
38
38
|
private showSuccessFn: ((message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void) | null = null
|
|
39
|
+
private showToastFn: ToastContextType['showToast'] | null = null
|
|
39
40
|
|
|
40
41
|
static getInstance(): ToastManager {
|
|
41
42
|
if (!ToastManager.instance) {
|
|
@@ -44,14 +45,16 @@ class ToastManager {
|
|
|
44
45
|
return ToastManager.instance
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn) {
|
|
48
|
+
register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn, showToast: ToastContextType['showToast']) {
|
|
48
49
|
this.showErrorFn = showError
|
|
49
50
|
this.showSuccessFn = showSuccess
|
|
51
|
+
this.showToastFn = showToast
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
unregister() {
|
|
53
55
|
this.showErrorFn = null
|
|
54
56
|
this.showSuccessFn = null
|
|
57
|
+
this.showToastFn = null
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
showError(message: string, detail?: string) {
|
|
@@ -61,6 +64,10 @@ class ToastManager {
|
|
|
61
64
|
showSuccess(message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) {
|
|
62
65
|
this.showSuccessFn ? this.showSuccessFn(message, detail, action, onDetailClick) : console.log('[Toast]', message, detail)
|
|
63
66
|
}
|
|
67
|
+
|
|
68
|
+
showNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
|
|
69
|
+
this.showToastFn ? this.showToastFn(message, { detail, type }) : console.warn('[Toast]', message, detail)
|
|
70
|
+
}
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
const toastManager = ToastManager.getInstance()
|
|
@@ -73,6 +80,10 @@ export function showApiSuccess(message: string, detail?: string, action?: Toast[
|
|
|
73
80
|
toastManager.showSuccess(message, detail, action, onDetailClick)
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
export function showApiNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
|
|
84
|
+
toastManager.showNotice(message, detail, type)
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
export function useToast() {
|
|
77
88
|
const context = useContext(ToastContext)
|
|
78
89
|
if (!context) {
|
|
@@ -138,9 +149,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
138
149
|
|
|
139
150
|
// Wire up singleton for use outside React components
|
|
140
151
|
useEffect(() => {
|
|
141
|
-
toastManager.register(showError, showSuccess)
|
|
152
|
+
toastManager.register(showError, showSuccess, showToast)
|
|
142
153
|
return () => toastManager.unregister()
|
|
143
|
-
}, [showError, showSuccess])
|
|
154
|
+
}, [showError, showSuccess, showToast])
|
|
144
155
|
|
|
145
156
|
return (
|
|
146
157
|
<ToastContext.Provider value={{ showToast, showCopied, showError, showSuccess }}>
|
|
@@ -233,7 +244,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
|
|
233
244
|
{toast.detail}
|
|
234
245
|
</button>
|
|
235
246
|
) : (
|
|
236
|
-
<p className={clsx('mt-1 text-xs break-words', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
|
|
247
|
+
<p className={clsx('mt-1 text-xs break-words whitespace-pre-line', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
|
|
237
248
|
{toast.detail}
|
|
238
249
|
</p>
|
|
239
250
|
)
|