@skyhook-io/k8s-ui 1.0.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/package.json +63 -0
- package/src/components/dock/BottomDock.tsx +204 -0
- package/src/components/dock/DockContext.tsx +212 -0
- package/src/components/dock/TerminalTab.tsx +333 -0
- package/src/components/dock/index.ts +3 -0
- package/src/components/gitops/GitOpsActions.tsx +227 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +221 -0
- package/src/components/gitops/ManagedResourcesList.tsx +210 -0
- package/src/components/gitops/SyncCountdown.tsx +146 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/logs/JsonLogLine.tsx +90 -0
- package/src/components/logs/LogCore.tsx +553 -0
- package/src/components/logs/LogToolbarSelects.tsx +74 -0
- package/src/components/logs/LogsViewer.tsx +146 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +263 -0
- package/src/components/logs/index.ts +13 -0
- package/src/components/logs/useLogBuffer.ts +138 -0
- package/src/components/logs/useLogSearch.ts +151 -0
- package/src/components/logs/useLogStream.ts +61 -0
- package/src/components/resources/ResourcesView.tsx +5494 -0
- package/src/components/resources/index.ts +17 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +166 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +233 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +89 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +194 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +79 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +68 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +209 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +123 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +111 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +164 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +140 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +153 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +221 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +124 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +86 -0
- package/src/components/resources/renderers/EventRenderer.tsx +175 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +119 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +172 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +242 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +263 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +68 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +218 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +476 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +154 -0
- package/src/components/resources/renderers/HPARenderer.tsx +164 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +303 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +167 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +104 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +175 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +176 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +125 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +84 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +112 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +288 -0
- package/src/components/resources/renderers/JobRenderer.tsx +103 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +157 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +188 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +148 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +90 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +136 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +151 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +70 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +309 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +142 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +232 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +97 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +74 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +138 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +220 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +151 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +352 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +93 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +208 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +282 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +153 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +108 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +98 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +138 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +126 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +63 -0
- package/src/components/resources/renderers/PodRenderer.tsx +677 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +50 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +62 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +71 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +97 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +192 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +227 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +75 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +98 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +121 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +321 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +111 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +81 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +65 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +98 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +195 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +82 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +257 -0
- package/src/components/resources/renderers/VPARenderer.tsx +193 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +73 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +193 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +161 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +134 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +36 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +347 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +156 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +202 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +91 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +311 -0
- package/src/components/resources/renderers/argo-cells.tsx +118 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +164 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +161 -0
- package/src/components/resources/renderers/eso-cells.tsx +115 -0
- package/src/components/resources/renderers/flux-cells.tsx +253 -0
- package/src/components/resources/renderers/index.ts +105 -0
- package/src/components/resources/renderers/istio-cells.tsx +210 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +107 -0
- package/src/components/resources/renderers/keda-cells.tsx +94 -0
- package/src/components/resources/renderers/knative-cells.tsx +396 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +85 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +74 -0
- package/src/components/resources/renderers/traefik-cells.tsx +120 -0
- package/src/components/resources/renderers/trivy-cells.tsx +153 -0
- package/src/components/resources/renderers/trivy-shared.tsx +67 -0
- package/src/components/resources/renderers/velero-cells.tsx +164 -0
- package/src/components/resources/resource-utils-argo.ts +155 -0
- package/src/components/resources/resource-utils-certmanager.ts +198 -0
- package/src/components/resources/resource-utils-cnpg.ts +344 -0
- package/src/components/resources/resource-utils-eso.ts +385 -0
- package/src/components/resources/resource-utils-flux.ts +186 -0
- package/src/components/resources/resource-utils-istio.ts +372 -0
- package/src/components/resources/resource-utils-karpenter.ts +150 -0
- package/src/components/resources/resource-utils-keda.ts +203 -0
- package/src/components/resources/resource-utils-knative.ts +218 -0
- package/src/components/resources/resource-utils-kyverno.ts +224 -0
- package/src/components/resources/resource-utils-prometheus.ts +159 -0
- package/src/components/resources/resource-utils-traefik.ts +180 -0
- package/src/components/resources/resource-utils-trivy.ts +146 -0
- package/src/components/resources/resource-utils-velero.ts +293 -0
- package/src/components/resources/resource-utils.ts +1558 -0
- package/src/components/shared/EditableYamlView.tsx +302 -0
- package/src/components/shared/ResourceActionsBar.tsx +924 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +557 -0
- package/src/components/shared/index.ts +3 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/shared.tsx +614 -0
- package/src/components/topology/GroupNode.tsx +198 -0
- package/src/components/topology/K8sResourceNode.tsx +440 -0
- package/src/components/topology/TopologyControls.tsx +62 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +377 -0
- package/src/components/topology/TopologyGraph.tsx +748 -0
- package/src/components/topology/TopologySearch.tsx +304 -0
- package/src/components/topology/index.ts +7 -0
- package/src/components/topology/layout.ts +734 -0
- package/src/components/topology/layout.worker.ts +272 -0
- package/src/components/topology/topology.css +129 -0
- package/src/components/ui/CodeViewer.tsx +451 -0
- package/src/components/ui/ConfirmDialog.tsx +188 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +57 -0
- package/src/components/ui/HealthRing.tsx +72 -0
- package/src/components/ui/MetricsChart.tsx +259 -0
- package/src/components/ui/ResourceBar.tsx +68 -0
- package/src/components/ui/Toast.tsx +225 -0
- package/src/components/ui/Tooltip.tsx +165 -0
- package/src/components/ui/YamlEditor.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +792 -0
- package/src/components/ui/index.ts +10 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +158 -0
- package/src/components/workload/WorkloadView.tsx +1109 -0
- package/src/components/workload/index.ts +2 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useAnimatedUnmount.ts +30 -0
- package/src/hooks/useKeyboardShortcuts.tsx +336 -0
- package/src/hooks/useRefreshAnimation.ts +58 -0
- package/src/index.ts +35 -0
- package/src/types/core.ts +894 -0
- package/src/types/gitops.ts +458 -0
- package/src/types/index.ts +2 -0
- package/src/utils/animation.ts +40 -0
- package/src/utils/api-resources.ts +185 -0
- package/src/utils/badge-colors.ts +399 -0
- package/src/utils/context-name.ts +72 -0
- package/src/utils/download.ts +19 -0
- package/src/utils/format.ts +233 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/log-format.ts +300 -0
- package/src/utils/navigation.ts +119 -0
- package/src/utils/resource-hierarchy.ts +630 -0
- package/src/utils/resource-icons.ts +206 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { clsx } from 'clsx'
|
|
2
|
+
import { CheckCircle2, AlertCircle, Loader2, Pause, HelpCircle, XCircle } from 'lucide-react'
|
|
3
|
+
import type { GitOpsStatus, SyncStatus, GitOpsHealthStatus } from '../../types/gitops'
|
|
4
|
+
|
|
5
|
+
interface GitOpsStatusBadgeProps {
|
|
6
|
+
status: GitOpsStatus
|
|
7
|
+
showHealth?: boolean
|
|
8
|
+
compact?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Unified status badge for GitOps resources (FluxCD and ArgoCD)
|
|
13
|
+
* Shows sync status with optional health indicator
|
|
14
|
+
*/
|
|
15
|
+
export function GitOpsStatusBadge({ status, showHealth = true, compact = false }: GitOpsStatusBadgeProps) {
|
|
16
|
+
const Icon = getStatusIcon(status)
|
|
17
|
+
const colorClass = getStatusColorClass(status)
|
|
18
|
+
const label = getStatusLabel(status)
|
|
19
|
+
|
|
20
|
+
if (compact) {
|
|
21
|
+
return (
|
|
22
|
+
<span
|
|
23
|
+
className={clsx('px-2 py-0.5 rounded text-xs font-medium inline-flex items-center gap-1', colorClass)}
|
|
24
|
+
title={status.message}
|
|
25
|
+
>
|
|
26
|
+
<Icon className="w-3 h-3" />
|
|
27
|
+
{label}
|
|
28
|
+
</span>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Don't show health indicator if the main badge already shows health status
|
|
33
|
+
// (i.e., when the main label is 'Degraded', 'Progressing', or 'Suspended')
|
|
34
|
+
const mainLabelShowsHealth = label === 'Degraded' || label === 'Progressing' || label === 'Suspended'
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex items-center gap-2">
|
|
38
|
+
<span
|
|
39
|
+
className={clsx('px-2 py-0.5 rounded text-xs font-medium inline-flex items-center gap-1', colorClass)}
|
|
40
|
+
title={status.message}
|
|
41
|
+
>
|
|
42
|
+
<Icon className="w-3.5 h-3.5" />
|
|
43
|
+
{label}
|
|
44
|
+
</span>
|
|
45
|
+
{showHealth && !mainLabelShowsHealth && status.health !== 'Unknown' && status.health !== getHealthFromSync(status.sync) && (
|
|
46
|
+
<HealthIndicator health={status.health} />
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getHealthFromSync(sync: SyncStatus): GitOpsHealthStatus {
|
|
53
|
+
if (sync === 'Synced') return 'Healthy'
|
|
54
|
+
if (sync === 'Reconciling') return 'Progressing'
|
|
55
|
+
return 'Unknown'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getStatusIcon(status: GitOpsStatus) {
|
|
59
|
+
if (status.suspended) return Pause
|
|
60
|
+
if (status.sync === 'Reconciling') return Loader2
|
|
61
|
+
if (status.sync === 'Synced' && status.health === 'Healthy') return CheckCircle2
|
|
62
|
+
if (status.health === 'Degraded') return XCircle
|
|
63
|
+
if (status.sync === 'OutOfSync') return AlertCircle
|
|
64
|
+
if (status.health === 'Progressing') return Loader2
|
|
65
|
+
return HelpCircle
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getStatusColorClass(status: GitOpsStatus): string {
|
|
69
|
+
if (status.suspended) {
|
|
70
|
+
return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
|
|
71
|
+
}
|
|
72
|
+
if (status.sync === 'Synced' && status.health === 'Healthy') {
|
|
73
|
+
return 'bg-green-500/20 text-green-400 border border-green-500/30'
|
|
74
|
+
}
|
|
75
|
+
if (status.health === 'Degraded') {
|
|
76
|
+
return 'bg-red-500/20 text-red-400 border border-red-500/30'
|
|
77
|
+
}
|
|
78
|
+
if (status.sync === 'OutOfSync') {
|
|
79
|
+
return 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
|
80
|
+
}
|
|
81
|
+
if (status.sync === 'Reconciling' || status.health === 'Progressing') {
|
|
82
|
+
return 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
|
|
83
|
+
}
|
|
84
|
+
return 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getStatusLabel(status: GitOpsStatus): string {
|
|
88
|
+
if (status.suspended) return 'Suspended'
|
|
89
|
+
if (status.sync === 'Reconciling') return 'Syncing'
|
|
90
|
+
if (status.sync === 'Synced' && status.health === 'Healthy') return 'Synced'
|
|
91
|
+
if (status.health === 'Degraded') return 'Degraded'
|
|
92
|
+
if (status.sync === 'OutOfSync') return 'OutOfSync'
|
|
93
|
+
if (status.health === 'Progressing') return 'Progressing'
|
|
94
|
+
return 'Unknown'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface HealthIndicatorProps {
|
|
98
|
+
health: GitOpsHealthStatus
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function HealthIndicator({ health }: HealthIndicatorProps) {
|
|
102
|
+
const { icon: Icon, color, label } = getHealthInfo(health)
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<span
|
|
106
|
+
className={clsx('px-1.5 py-0.5 rounded text-xs inline-flex items-center gap-1', color)}
|
|
107
|
+
title={`Health: ${label}`}
|
|
108
|
+
>
|
|
109
|
+
<Icon className="w-3 h-3" />
|
|
110
|
+
{label}
|
|
111
|
+
</span>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getHealthInfo(health: GitOpsHealthStatus) {
|
|
116
|
+
switch (health) {
|
|
117
|
+
case 'Healthy':
|
|
118
|
+
return {
|
|
119
|
+
icon: CheckCircle2,
|
|
120
|
+
color: 'bg-green-500/10 text-green-400',
|
|
121
|
+
label: 'Healthy',
|
|
122
|
+
}
|
|
123
|
+
case 'Progressing':
|
|
124
|
+
return {
|
|
125
|
+
icon: Loader2,
|
|
126
|
+
color: 'bg-blue-500/10 text-blue-400',
|
|
127
|
+
label: 'Progressing',
|
|
128
|
+
}
|
|
129
|
+
case 'Degraded':
|
|
130
|
+
return {
|
|
131
|
+
icon: XCircle,
|
|
132
|
+
color: 'bg-red-500/10 text-red-400',
|
|
133
|
+
label: 'Degraded',
|
|
134
|
+
}
|
|
135
|
+
case 'Suspended':
|
|
136
|
+
return {
|
|
137
|
+
icon: Pause,
|
|
138
|
+
color: 'bg-yellow-500/10 text-yellow-400',
|
|
139
|
+
label: 'Suspended',
|
|
140
|
+
}
|
|
141
|
+
case 'Missing':
|
|
142
|
+
return {
|
|
143
|
+
icon: AlertCircle,
|
|
144
|
+
color: 'bg-orange-500/10 text-orange-400',
|
|
145
|
+
label: 'Missing',
|
|
146
|
+
}
|
|
147
|
+
default:
|
|
148
|
+
return {
|
|
149
|
+
icon: HelpCircle,
|
|
150
|
+
color: 'bg-gray-500/10 text-gray-400',
|
|
151
|
+
label: 'Unknown',
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Simple sync status badge without health indicator
|
|
158
|
+
*/
|
|
159
|
+
export function SyncStatusBadge({ sync, suspended }: { sync: SyncStatus; suspended?: boolean }) {
|
|
160
|
+
if (suspended) {
|
|
161
|
+
return (
|
|
162
|
+
<span className="px-2 py-0.5 rounded text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 inline-flex items-center gap-1">
|
|
163
|
+
<Pause className="w-3 h-3" />
|
|
164
|
+
Suspended
|
|
165
|
+
</span>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const config = getSyncConfig(sync)
|
|
170
|
+
const Icon = config.icon
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<span className={clsx('px-2 py-0.5 rounded text-xs font-medium inline-flex items-center gap-1', config.color)}>
|
|
174
|
+
<Icon className={clsx('w-3 h-3', sync === 'Reconciling' && 'animate-spin')} />
|
|
175
|
+
{config.label}
|
|
176
|
+
</span>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getSyncConfig(sync: SyncStatus) {
|
|
181
|
+
switch (sync) {
|
|
182
|
+
case 'Synced':
|
|
183
|
+
return {
|
|
184
|
+
icon: CheckCircle2,
|
|
185
|
+
color: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
|
186
|
+
label: 'Synced',
|
|
187
|
+
}
|
|
188
|
+
case 'OutOfSync':
|
|
189
|
+
return {
|
|
190
|
+
icon: AlertCircle,
|
|
191
|
+
color: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
|
|
192
|
+
label: 'OutOfSync',
|
|
193
|
+
}
|
|
194
|
+
case 'Reconciling':
|
|
195
|
+
return {
|
|
196
|
+
icon: Loader2,
|
|
197
|
+
color: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
|
198
|
+
label: 'Syncing',
|
|
199
|
+
}
|
|
200
|
+
default:
|
|
201
|
+
return {
|
|
202
|
+
icon: HelpCircle,
|
|
203
|
+
color: 'bg-gray-500/20 text-gray-400 border border-gray-500/30',
|
|
204
|
+
label: 'Unknown',
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Simple health status badge
|
|
211
|
+
*/
|
|
212
|
+
export function HealthStatusBadge({ health }: { health: GitOpsHealthStatus }) {
|
|
213
|
+
const { icon: Icon, color, label } = getHealthInfo(health)
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<span className={clsx('px-2 py-0.5 rounded text-xs font-medium inline-flex items-center gap-1', color)}>
|
|
217
|
+
<Icon className={clsx('w-3 h-3', health === 'Progressing' && 'animate-spin')} />
|
|
218
|
+
{label}
|
|
219
|
+
</span>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ChevronDown, ChevronRight, Package, ExternalLink } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import type { ManagedResource, GitOpsHealthStatus } from '../../types/gitops'
|
|
5
|
+
import { groupManagedResourcesByKind } from '../../types/gitops'
|
|
6
|
+
|
|
7
|
+
interface ManagedResourcesListProps {
|
|
8
|
+
resources: ManagedResource[]
|
|
9
|
+
/** Callback when a resource is clicked (for navigation) */
|
|
10
|
+
onResourceClick?: (resource: ManagedResource) => void
|
|
11
|
+
/** Maximum resources to show before collapsing */
|
|
12
|
+
maxVisible?: number
|
|
13
|
+
/** Title for the section */
|
|
14
|
+
title?: string
|
|
15
|
+
/** Whether to show health status for each resource */
|
|
16
|
+
showHealth?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Displays a list of managed resources grouped by kind
|
|
21
|
+
* Used for showing Flux inventory or ArgoCD managed resources
|
|
22
|
+
*/
|
|
23
|
+
export function ManagedResourcesList({
|
|
24
|
+
resources,
|
|
25
|
+
onResourceClick,
|
|
26
|
+
maxVisible = 20,
|
|
27
|
+
title = 'Managed Resources',
|
|
28
|
+
showHealth = false,
|
|
29
|
+
}: ManagedResourcesListProps) {
|
|
30
|
+
const [expanded, setExpanded] = useState(resources.length <= 10)
|
|
31
|
+
const [showAll, setShowAll] = useState(false)
|
|
32
|
+
|
|
33
|
+
if (!resources || resources.length === 0) {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const grouped = groupManagedResourcesByKind(resources)
|
|
38
|
+
const sortedGroups = Array.from(grouped.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
|
39
|
+
const hasMore = resources.length > maxVisible && !showAll
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="border-b-subtle pb-4 last:border-0">
|
|
43
|
+
<button
|
|
44
|
+
onClick={() => setExpanded(!expanded)}
|
|
45
|
+
className="flex items-center gap-2 w-full text-left mb-2 hover:text-theme-text-primary transition-colors"
|
|
46
|
+
>
|
|
47
|
+
{expanded ? (
|
|
48
|
+
<ChevronDown className="w-4 h-4 text-theme-text-tertiary" />
|
|
49
|
+
) : (
|
|
50
|
+
<ChevronRight className="w-4 h-4 text-theme-text-tertiary" />
|
|
51
|
+
)}
|
|
52
|
+
<Package className="w-4 h-4 text-theme-text-secondary" />
|
|
53
|
+
<span className="text-sm font-medium text-theme-text-secondary">
|
|
54
|
+
{title} ({resources.length})
|
|
55
|
+
</span>
|
|
56
|
+
</button>
|
|
57
|
+
|
|
58
|
+
{expanded && (
|
|
59
|
+
<div className="pl-6 space-y-3">
|
|
60
|
+
{sortedGroups.map(([kind, kindResources]) => (
|
|
61
|
+
<ResourceKindGroup
|
|
62
|
+
key={kind}
|
|
63
|
+
kind={kind}
|
|
64
|
+
resources={showAll ? kindResources : kindResources.slice(0, Math.ceil(maxVisible / sortedGroups.length))}
|
|
65
|
+
onResourceClick={onResourceClick}
|
|
66
|
+
showHealth={showHealth}
|
|
67
|
+
/>
|
|
68
|
+
))}
|
|
69
|
+
|
|
70
|
+
{hasMore && (
|
|
71
|
+
<button
|
|
72
|
+
onClick={() => setShowAll(true)}
|
|
73
|
+
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
|
74
|
+
>
|
|
75
|
+
Show all {resources.length} resources...
|
|
76
|
+
</button>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ResourceKindGroupProps {
|
|
85
|
+
kind: string
|
|
86
|
+
resources: ManagedResource[]
|
|
87
|
+
onResourceClick?: (resource: ManagedResource) => void
|
|
88
|
+
showHealth?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ResourceKindGroup({ kind, resources, onResourceClick, showHealth }: ResourceKindGroupProps) {
|
|
92
|
+
const [expanded, setExpanded] = useState(resources.length <= 5)
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div>
|
|
96
|
+
<button
|
|
97
|
+
onClick={() => setExpanded(!expanded)}
|
|
98
|
+
className="flex items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors mb-1"
|
|
99
|
+
>
|
|
100
|
+
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
101
|
+
<span className="font-medium">{kind}</span>
|
|
102
|
+
<span className="text-theme-text-tertiary">({resources.length})</span>
|
|
103
|
+
</button>
|
|
104
|
+
|
|
105
|
+
{expanded && (
|
|
106
|
+
<div className="ml-4 space-y-0.5">
|
|
107
|
+
{resources.map((resource, idx) => (
|
|
108
|
+
<ResourceItem
|
|
109
|
+
key={`${resource.namespace}-${resource.name}-${idx}`}
|
|
110
|
+
resource={resource}
|
|
111
|
+
onClick={onResourceClick}
|
|
112
|
+
showHealth={showHealth}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ResourceItemProps {
|
|
122
|
+
resource: ManagedResource
|
|
123
|
+
onClick?: (resource: ManagedResource) => void
|
|
124
|
+
showHealth?: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ResourceItem({ resource, onClick, showHealth }: ResourceItemProps) {
|
|
128
|
+
const displayName = resource.namespace
|
|
129
|
+
? `${resource.namespace}/${resource.name}`
|
|
130
|
+
: resource.name
|
|
131
|
+
|
|
132
|
+
const content = (
|
|
133
|
+
<div className="flex items-center gap-2 py-0.5">
|
|
134
|
+
{showHealth && resource.health && (
|
|
135
|
+
<HealthDot health={resource.health} />
|
|
136
|
+
)}
|
|
137
|
+
<span className="text-xs text-theme-text-secondary truncate" title={displayName}>
|
|
138
|
+
{displayName}
|
|
139
|
+
</span>
|
|
140
|
+
{onClick && (
|
|
141
|
+
<ExternalLink className="w-3 h-3 text-theme-text-tertiary opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if (onClick) {
|
|
147
|
+
return (
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => onClick(resource)}
|
|
150
|
+
className="group w-full text-left hover:bg-theme-elevated/30 rounded px-1 -mx-1 transition-colors"
|
|
151
|
+
>
|
|
152
|
+
{content}
|
|
153
|
+
</button>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return <div className="px-1 -mx-1">{content}</div>
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function HealthDot({ health }: { health: GitOpsHealthStatus }) {
|
|
161
|
+
const colorClass = getHealthDotColor(health)
|
|
162
|
+
return (
|
|
163
|
+
<span
|
|
164
|
+
className={clsx('w-1.5 h-1.5 rounded-full shrink-0', colorClass)}
|
|
165
|
+
title={health}
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getHealthDotColor(health: GitOpsHealthStatus): string {
|
|
171
|
+
switch (health) {
|
|
172
|
+
case 'Healthy':
|
|
173
|
+
return 'bg-green-400'
|
|
174
|
+
case 'Progressing':
|
|
175
|
+
return 'bg-blue-400'
|
|
176
|
+
case 'Degraded':
|
|
177
|
+
return 'bg-red-400'
|
|
178
|
+
case 'Suspended':
|
|
179
|
+
return 'bg-yellow-400'
|
|
180
|
+
case 'Missing':
|
|
181
|
+
return 'bg-orange-400'
|
|
182
|
+
default:
|
|
183
|
+
return 'bg-gray-400'
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Compact inventory count display for table cells
|
|
189
|
+
*/
|
|
190
|
+
export function InventoryCount({ count, healthy, unhealthy }: { count: number; healthy?: number; unhealthy?: number }) {
|
|
191
|
+
if (count === 0) {
|
|
192
|
+
return <span className="text-theme-text-tertiary">-</span>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (healthy !== undefined && unhealthy !== undefined) {
|
|
196
|
+
return (
|
|
197
|
+
<span className="text-xs">
|
|
198
|
+
<span className="text-green-400">{healthy}</span>
|
|
199
|
+
<span className="text-theme-text-tertiary">/</span>
|
|
200
|
+
<span className={unhealthy > 0 ? 'text-red-400' : 'text-theme-text-secondary'}>{count}</span>
|
|
201
|
+
</span>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<span className="text-xs text-theme-text-secondary">
|
|
207
|
+
{count} {count === 1 ? 'resource' : 'resources'}
|
|
208
|
+
</span>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Clock, RefreshCw } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
|
|
5
|
+
interface SyncCountdownProps {
|
|
6
|
+
/** The reconciliation interval (e.g., "5m", "1h", "30s") */
|
|
7
|
+
interval: string
|
|
8
|
+
/** Last reconciliation time (ISO string) */
|
|
9
|
+
lastSyncTime?: string
|
|
10
|
+
/** Whether the resource is suspended */
|
|
11
|
+
suspended?: boolean
|
|
12
|
+
/** Whether to show a compact version */
|
|
13
|
+
compact?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Displays a countdown to the next reconciliation
|
|
18
|
+
*/
|
|
19
|
+
export function SyncCountdown({ interval, lastSyncTime, suspended, compact = false }: SyncCountdownProps) {
|
|
20
|
+
const [secondsRemaining, setSecondsRemaining] = useState<number | null>(null)
|
|
21
|
+
|
|
22
|
+
const intervalSeconds = parseInterval(interval)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (suspended || !lastSyncTime || intervalSeconds === null) {
|
|
26
|
+
setSecondsRemaining(null)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const calculateRemaining = () => {
|
|
31
|
+
const lastSync = new Date(lastSyncTime).getTime()
|
|
32
|
+
const nextSync = lastSync + intervalSeconds * 1000
|
|
33
|
+
const now = Date.now()
|
|
34
|
+
const remaining = Math.max(0, Math.floor((nextSync - now) / 1000))
|
|
35
|
+
return remaining
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setSecondsRemaining(calculateRemaining())
|
|
39
|
+
|
|
40
|
+
const timer = setInterval(() => {
|
|
41
|
+
setSecondsRemaining(calculateRemaining())
|
|
42
|
+
}, 1000)
|
|
43
|
+
|
|
44
|
+
return () => clearInterval(timer)
|
|
45
|
+
}, [interval, lastSyncTime, suspended, intervalSeconds])
|
|
46
|
+
|
|
47
|
+
if (suspended) {
|
|
48
|
+
return (
|
|
49
|
+
<div className={clsx('flex items-center gap-1.5', compact ? 'text-xs' : 'text-sm')}>
|
|
50
|
+
<Clock className={clsx('text-yellow-400', compact ? 'w-3 h-3' : 'w-3.5 h-3.5')} />
|
|
51
|
+
<span className="text-yellow-400">Paused</span>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (secondsRemaining === null) {
|
|
57
|
+
return (
|
|
58
|
+
<div className={clsx('flex items-center gap-1.5 text-theme-text-tertiary', compact ? 'text-xs' : 'text-sm')}>
|
|
59
|
+
<Clock className={compact ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
|
|
60
|
+
<span>{interval}</span>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isImminent = secondsRemaining < 30
|
|
66
|
+
const formatted = formatSecondsRemaining(secondsRemaining)
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
className={clsx(
|
|
71
|
+
'flex items-center gap-1.5',
|
|
72
|
+
compact ? 'text-xs' : 'text-sm',
|
|
73
|
+
isImminent ? 'text-blue-400' : 'text-theme-text-secondary'
|
|
74
|
+
)}
|
|
75
|
+
title={`Interval: ${interval}`}
|
|
76
|
+
>
|
|
77
|
+
{isImminent ? (
|
|
78
|
+
<RefreshCw className={clsx('animate-pulse', compact ? 'w-3 h-3' : 'w-3.5 h-3.5')} />
|
|
79
|
+
) : (
|
|
80
|
+
<Clock className={compact ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
|
|
81
|
+
)}
|
|
82
|
+
<span>
|
|
83
|
+
{secondsRemaining === 0 ? 'Now' : `in ${formatted}`}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parses a Kubernetes duration string (e.g., "5m", "1h30m", "30s") into seconds
|
|
91
|
+
*/
|
|
92
|
+
function parseInterval(interval: string): number | null {
|
|
93
|
+
if (!interval) return null
|
|
94
|
+
|
|
95
|
+
let totalSeconds = 0
|
|
96
|
+
const regex = /(\d+)(h|m|s)/g
|
|
97
|
+
let match
|
|
98
|
+
|
|
99
|
+
while ((match = regex.exec(interval)) !== null) {
|
|
100
|
+
const value = parseInt(match[1], 10)
|
|
101
|
+
const unit = match[2]
|
|
102
|
+
|
|
103
|
+
switch (unit) {
|
|
104
|
+
case 'h':
|
|
105
|
+
totalSeconds += value * 3600
|
|
106
|
+
break
|
|
107
|
+
case 'm':
|
|
108
|
+
totalSeconds += value * 60
|
|
109
|
+
break
|
|
110
|
+
case 's':
|
|
111
|
+
totalSeconds += value
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return totalSeconds > 0 ? totalSeconds : null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Formats seconds into a human-readable duration
|
|
121
|
+
*/
|
|
122
|
+
function formatSecondsRemaining(seconds: number): string {
|
|
123
|
+
if (seconds < 60) {
|
|
124
|
+
return `${seconds}s`
|
|
125
|
+
}
|
|
126
|
+
if (seconds < 3600) {
|
|
127
|
+
const mins = Math.floor(seconds / 60)
|
|
128
|
+
const secs = seconds % 60
|
|
129
|
+
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
|
|
130
|
+
}
|
|
131
|
+
const hours = Math.floor(seconds / 3600)
|
|
132
|
+
const mins = Math.floor((seconds % 3600) / 60)
|
|
133
|
+
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Simple interval display without countdown
|
|
138
|
+
*/
|
|
139
|
+
export function IntervalDisplay({ interval, compact = false }: { interval: string; compact?: boolean }) {
|
|
140
|
+
return (
|
|
141
|
+
<div className={clsx('flex items-center gap-1.5 text-theme-text-secondary', compact ? 'text-xs' : 'text-sm')}>
|
|
142
|
+
<Clock className={compact ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
|
|
143
|
+
<span>{interval}</span>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { GitOpsStatusBadge, SyncStatusBadge, HealthStatusBadge } from './GitOpsStatusBadge'
|
|
2
|
+
export { SyncCountdown, IntervalDisplay } from './SyncCountdown'
|
|
3
|
+
export { ManagedResourcesList, InventoryCount } from './ManagedResourcesList'
|
|
4
|
+
export { GitOpsActions, SyncButton, SuspendToggle } from './GitOpsActions'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react'
|
|
2
|
+
import { ChevronRight, ChevronDown } from 'lucide-react'
|
|
3
|
+
import { getLevelColor } from '../../utils/log-format'
|
|
4
|
+
import type { LogLevel } from './useLogBuffer'
|
|
5
|
+
|
|
6
|
+
interface JsonLogLineProps {
|
|
7
|
+
content: string
|
|
8
|
+
level: LogLevel
|
|
9
|
+
wordWrap: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function JsonLogLine({ content, level, wordWrap }: JsonLogLineProps) {
|
|
13
|
+
const [expanded, setExpanded] = useState(false)
|
|
14
|
+
const levelColor = getLevelColor(level)
|
|
15
|
+
|
|
16
|
+
const parsed = useMemo(() => {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(content.trim())
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}, [content])
|
|
23
|
+
|
|
24
|
+
if (!parsed) {
|
|
25
|
+
return (
|
|
26
|
+
<span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}>
|
|
27
|
+
{content}
|
|
28
|
+
</span>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const summary = buildSummary(parsed)
|
|
33
|
+
const fieldCount = Object.keys(parsed).length
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<span className={levelColor}>
|
|
37
|
+
<button
|
|
38
|
+
onClick={() => setExpanded(!expanded)}
|
|
39
|
+
className="inline-flex items-center gap-0.5 hover:bg-theme-surface/50 rounded px-0.5 -ml-0.5 align-middle"
|
|
40
|
+
>
|
|
41
|
+
{expanded
|
|
42
|
+
? <ChevronDown className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
|
|
43
|
+
: <ChevronRight className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
|
|
44
|
+
}
|
|
45
|
+
</button>
|
|
46
|
+
{!expanded ? (
|
|
47
|
+
<span className={wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}>
|
|
48
|
+
{summary}
|
|
49
|
+
<span className="text-theme-text-tertiary ml-1">{`{${fieldCount} fields}`}</span>
|
|
50
|
+
</span>
|
|
51
|
+
) : (
|
|
52
|
+
<span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
|
|
53
|
+
{formatJsonExpanded(parsed)}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</span>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildSummary(obj: Record<string, unknown>): string {
|
|
61
|
+
const parts: string[] = []
|
|
62
|
+
|
|
63
|
+
// Level
|
|
64
|
+
const lvl = obj.level || obj.severity || obj.lvl
|
|
65
|
+
if (typeof lvl === 'string') {
|
|
66
|
+
parts.push(lvl.toUpperCase())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Message
|
|
70
|
+
const msg = obj.msg || obj.message
|
|
71
|
+
if (typeof msg === 'string') {
|
|
72
|
+
parts.push(msg)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Error
|
|
76
|
+
const err = obj.error || obj.err
|
|
77
|
+
if (typeof err === 'string') {
|
|
78
|
+
parts.push(`error=${err}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return parts.join(' ')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatJsonExpanded(obj: Record<string, unknown>): string {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.stringify(obj, null, 2)
|
|
87
|
+
} catch {
|
|
88
|
+
return String(obj)
|
|
89
|
+
}
|
|
90
|
+
}
|