@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 { useState, useMemo } from 'react'
|
|
2
|
+
import { ShieldCheck, ChevronDown, ChevronRight, CheckCircle2, XCircle } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { Section, PropertyList, Property } from '../../ui/drawer-components'
|
|
5
|
+
import { formatAge } from '../resource-utils'
|
|
6
|
+
import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER, TrivyAlertBanner } from './trivy-shared'
|
|
7
|
+
|
|
8
|
+
interface ConfigAuditReportRendererProps {
|
|
9
|
+
data: any
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface GroupedCheck {
|
|
13
|
+
checkID: string
|
|
14
|
+
title: string
|
|
15
|
+
severity: string
|
|
16
|
+
success: boolean
|
|
17
|
+
description: string
|
|
18
|
+
remediation: string
|
|
19
|
+
messages: string[]
|
|
20
|
+
count: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function groupChecks(checks: any[]): GroupedCheck[] {
|
|
24
|
+
const map = new Map<string, GroupedCheck>()
|
|
25
|
+
|
|
26
|
+
for (const check of checks) {
|
|
27
|
+
const id = check.checkID || 'unknown'
|
|
28
|
+
const existing = map.get(id)
|
|
29
|
+
if (existing) {
|
|
30
|
+
existing.count++
|
|
31
|
+
if (!check.success) existing.success = false
|
|
32
|
+
if ((SEVERITY_ORDER[check.severity] ?? 99) < (SEVERITY_ORDER[existing.severity] ?? 99)) {
|
|
33
|
+
existing.severity = check.severity
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(check.messages)) {
|
|
36
|
+
for (const msg of check.messages) {
|
|
37
|
+
if (msg && !existing.messages.includes(msg)) {
|
|
38
|
+
existing.messages.push(msg)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
map.set(id, {
|
|
44
|
+
checkID: id,
|
|
45
|
+
title: check.title || '-',
|
|
46
|
+
severity: check.severity || 'LOW',
|
|
47
|
+
success: check.success,
|
|
48
|
+
description: check.description || '',
|
|
49
|
+
remediation: check.remediation || '',
|
|
50
|
+
messages: Array.isArray(check.messages) ? [...check.messages] : [],
|
|
51
|
+
count: 1,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const grouped = Array.from(map.values())
|
|
57
|
+
grouped.sort((a, b) => {
|
|
58
|
+
if (a.success !== b.success) return a.success ? 1 : -1
|
|
59
|
+
return (SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99)
|
|
60
|
+
})
|
|
61
|
+
return grouped
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererProps) {
|
|
65
|
+
const [expandedSection, setExpandedSection] = useState(true)
|
|
66
|
+
const [expandedChecks, setExpandedChecks] = useState<Set<string>>(new Set())
|
|
67
|
+
|
|
68
|
+
const report = data.report || {}
|
|
69
|
+
const summary = report.summary || {}
|
|
70
|
+
const checks = Array.isArray(report.checks) ? report.checks : []
|
|
71
|
+
const scanner = report.scanner || {}
|
|
72
|
+
|
|
73
|
+
const critical = summary.criticalCount || 0
|
|
74
|
+
const high = summary.highCount || 0
|
|
75
|
+
const medium = summary.mediumCount || 0
|
|
76
|
+
const low = summary.lowCount || 0
|
|
77
|
+
|
|
78
|
+
const grouped = useMemo(() => groupChecks(checks), [checks])
|
|
79
|
+
const passCount = grouped.filter(g => g.success).length
|
|
80
|
+
const failCount = grouped.length - passCount
|
|
81
|
+
|
|
82
|
+
const containerCount = useMemo(() => {
|
|
83
|
+
const names = new Set<string>()
|
|
84
|
+
for (const check of checks) {
|
|
85
|
+
if (Array.isArray(check.messages)) {
|
|
86
|
+
for (const msg of check.messages) {
|
|
87
|
+
const match = msg?.match(/Container '([^']+)'/)
|
|
88
|
+
if (match) names.add(match[1])
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return names.size
|
|
93
|
+
}, [checks])
|
|
94
|
+
|
|
95
|
+
const toggleCheck = (checkID: string) => {
|
|
96
|
+
setExpandedChecks(prev => {
|
|
97
|
+
const next = new Set(prev)
|
|
98
|
+
if (next.has(checkID)) next.delete(checkID)
|
|
99
|
+
else next.add(checkID)
|
|
100
|
+
return next
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<>
|
|
106
|
+
<TrivyAlertBanner critical={critical} high={high} noun="finding" />
|
|
107
|
+
|
|
108
|
+
{/* Report Overview */}
|
|
109
|
+
<Section title="Report Overview" icon={ShieldCheck}>
|
|
110
|
+
<PropertyList>
|
|
111
|
+
<Property label="Scanner" value={scanner.name ? `${scanner.name} ${scanner.version || ''}`.trim() : '-'} />
|
|
112
|
+
<Property label="Scanned" value={report.updateTimestamp ? formatAge(report.updateTimestamp) + ' ago' : '-'} />
|
|
113
|
+
</PropertyList>
|
|
114
|
+
</Section>
|
|
115
|
+
|
|
116
|
+
{/* Summary */}
|
|
117
|
+
<Section title="Summary">
|
|
118
|
+
<div className="flex items-center gap-4 mb-3">
|
|
119
|
+
<div className="flex items-center gap-1.5">
|
|
120
|
+
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
|
121
|
+
<span className="text-sm font-medium text-green-400">{passCount}</span>
|
|
122
|
+
<span className="text-xs text-theme-text-tertiary">passed</span>
|
|
123
|
+
</div>
|
|
124
|
+
<div className="flex items-center gap-1.5">
|
|
125
|
+
<XCircle className="w-4 h-4 text-red-400" />
|
|
126
|
+
<span className="text-sm font-medium text-red-400">{failCount}</span>
|
|
127
|
+
<span className="text-xs text-theme-text-tertiary">failed</span>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex flex-wrap gap-2">
|
|
131
|
+
{[
|
|
132
|
+
{ label: 'Critical', count: critical, key: 'CRITICAL' },
|
|
133
|
+
{ label: 'High', count: high, key: 'HIGH' },
|
|
134
|
+
{ label: 'Medium', count: medium, key: 'MEDIUM' },
|
|
135
|
+
{ label: 'Low', count: low, key: 'LOW' },
|
|
136
|
+
].filter(({ count }) => count > 0).map(({ label, count, key }) => (
|
|
137
|
+
<span key={key} className={clsx('px-2 py-0.5 rounded text-xs font-medium', SEVERITY_BADGE_COLORS[key])}>
|
|
138
|
+
{count} {label}
|
|
139
|
+
</span>
|
|
140
|
+
))}
|
|
141
|
+
{failCount === 0 && (
|
|
142
|
+
<span className="px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-400">All checks passed</span>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</Section>
|
|
146
|
+
|
|
147
|
+
{/* Checks - grouped by checkID */}
|
|
148
|
+
{grouped.length > 0 && (
|
|
149
|
+
<Section title="Checks">
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => setExpandedSection(!expandedSection)}
|
|
152
|
+
className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
|
|
153
|
+
>
|
|
154
|
+
{expandedSection ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
|
155
|
+
{grouped.length} unique checks{containerCount > 0 ? ` (${containerCount} containers scanned)` : ''}
|
|
156
|
+
</button>
|
|
157
|
+
{expandedSection && (
|
|
158
|
+
<div className="space-y-0.5">
|
|
159
|
+
{grouped.map((group) => {
|
|
160
|
+
const isExpanded = expandedChecks.has(group.checkID)
|
|
161
|
+
return (
|
|
162
|
+
<div key={group.checkID}>
|
|
163
|
+
<button
|
|
164
|
+
onClick={() => toggleCheck(group.checkID)}
|
|
165
|
+
className="w-full flex items-center gap-2 py-1.5 px-1 rounded hover:bg-theme-hover/50 text-left"
|
|
166
|
+
>
|
|
167
|
+
{group.success ? (
|
|
168
|
+
<CheckCircle2 className="w-3.5 h-3.5 text-green-400 shrink-0" />
|
|
169
|
+
) : (
|
|
170
|
+
<XCircle className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
|
171
|
+
)}
|
|
172
|
+
<span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0', SEVERITY_BADGE_COLORS[group.severity] || 'bg-gray-500/20 text-gray-400')}>
|
|
173
|
+
{group.severity}
|
|
174
|
+
</span>
|
|
175
|
+
<span className="text-xs text-theme-text-secondary truncate flex-1">{group.title}</span>
|
|
176
|
+
{group.count > 1 && (
|
|
177
|
+
<span className="text-[10px] text-theme-text-tertiary shrink-0">{group.count} containers</span>
|
|
178
|
+
)}
|
|
179
|
+
{isExpanded ? (
|
|
180
|
+
<ChevronDown className="w-3 h-3 text-theme-text-tertiary shrink-0" />
|
|
181
|
+
) : (
|
|
182
|
+
<ChevronRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
|
|
183
|
+
)}
|
|
184
|
+
</button>
|
|
185
|
+
{isExpanded && (
|
|
186
|
+
<div className="ml-6 mr-1 mb-2 p-2.5 bg-theme-elevated/50 rounded border border-theme-border/50 space-y-2">
|
|
187
|
+
<div className="text-[10px] font-mono text-theme-text-tertiary">{group.checkID}</div>
|
|
188
|
+
{group.description && (
|
|
189
|
+
<div>
|
|
190
|
+
<div className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider mb-0.5">Description</div>
|
|
191
|
+
<div className="text-xs text-theme-text-secondary">{group.description}</div>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
{group.messages.length > 0 && (
|
|
195
|
+
<div>
|
|
196
|
+
<div className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider mb-0.5">Affected</div>
|
|
197
|
+
<div className="space-y-0.5">
|
|
198
|
+
{group.messages.map((msg, i) => (
|
|
199
|
+
<div key={i} className="text-xs text-theme-text-secondary">{msg}</div>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
{group.remediation && (
|
|
205
|
+
<div>
|
|
206
|
+
<div className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider mb-0.5">Remediation</div>
|
|
207
|
+
<div className="text-xs text-theme-text-secondary">{group.remediation}</div>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
})}
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</Section>
|
|
218
|
+
)}
|
|
219
|
+
</>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react'
|
|
2
|
+
import { AlertTriangle, Copy, Check } from 'lucide-react'
|
|
3
|
+
import { Section, ExpandableSection } from '../../ui/drawer-components'
|
|
4
|
+
import { Tooltip } from '../../ui/Tooltip'
|
|
5
|
+
import { formatBytes } from '../../../utils/format'
|
|
6
|
+
|
|
7
|
+
interface ConfigMapRendererProps {
|
|
8
|
+
data: any
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Short = single-line and under 120 chars — render inline
|
|
12
|
+
function isShortValue(value: string): boolean {
|
|
13
|
+
return !value.includes('\n') && value.length <= 120
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function TruncatedKey({ text }: { text: string }) {
|
|
17
|
+
const ref = useRef<HTMLSpanElement>(null)
|
|
18
|
+
const [isTruncated, setIsTruncated] = useState(false)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const el = ref.current
|
|
22
|
+
if (el) setIsTruncated(el.scrollWidth > el.clientWidth)
|
|
23
|
+
}, [text])
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Tooltip content={text} delay={100} position="top" disabled={!isTruncated}>
|
|
27
|
+
<span ref={ref} className="truncate block">{text}</span>
|
|
28
|
+
</Tooltip>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function CopyButton({ text }: { text: string }) {
|
|
33
|
+
const [copied, setCopied] = useState(false)
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
onClick={() => {
|
|
37
|
+
navigator.clipboard.writeText(text)
|
|
38
|
+
setCopied(true)
|
|
39
|
+
setTimeout(() => setCopied(false), 1500)
|
|
40
|
+
}}
|
|
41
|
+
className="p-0.5 text-theme-text-tertiary hover:text-theme-text-primary opacity-0 group-hover/row:opacity-100 transition-opacity shrink-0"
|
|
42
|
+
title="Copy value"
|
|
43
|
+
>
|
|
44
|
+
{copied ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
|
|
45
|
+
</button>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ConfigMapRenderer({ data }: ConfigMapRendererProps) {
|
|
50
|
+
const dataEntries = Object.entries((data.data || {}) as Record<string, string>)
|
|
51
|
+
const binaryDataKeys = Object.keys(data.binaryData || {})
|
|
52
|
+
|
|
53
|
+
const shortEntries = dataEntries.filter(([, v]) => isShortValue(v))
|
|
54
|
+
const longEntries = dataEntries.filter(([, v]) => !isShortValue(v))
|
|
55
|
+
|
|
56
|
+
// Dynamic value column width: give values as much room as they need (30-55%)
|
|
57
|
+
const maxValueLen = shortEntries.reduce((max, [, v]) => Math.max(max, v.length), 0)
|
|
58
|
+
// Short values (< 10 chars like "true", "5m") → values get 30%, keys get 70%
|
|
59
|
+
// Long values (URLs, addresses) → values get up to 55%
|
|
60
|
+
const valuePercent = Math.min(55, Math.max(30, Math.round(maxValueLen * 1.5 + 10)))
|
|
61
|
+
const keyPercent = 100 - valuePercent
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<Section title={`Data (${dataEntries.length + binaryDataKeys.length} keys)`} defaultExpanded>
|
|
66
|
+
<div className="space-y-3">
|
|
67
|
+
{/* Short values: compact table */}
|
|
68
|
+
{shortEntries.length > 0 && (
|
|
69
|
+
<table className="w-full bg-theme-base rounded overflow-hidden text-xs border-separate border-spacing-0 table-fixed">
|
|
70
|
+
<colgroup>
|
|
71
|
+
<col style={{ width: `${keyPercent}%` }} />
|
|
72
|
+
<col style={{ width: `${valuePercent}%` }} />
|
|
73
|
+
<col style={{ width: '20px' }} />
|
|
74
|
+
</colgroup>
|
|
75
|
+
<tbody>
|
|
76
|
+
{shortEntries.map(([key, value], i) => (
|
|
77
|
+
<tr key={key} className={`group/row ${i % 2 === 0 ? '' : 'bg-theme-elevated/30'}`}>
|
|
78
|
+
<td className="px-2.5 py-1.5 text-theme-text-secondary font-medium truncate align-top">
|
|
79
|
+
<TruncatedKey text={key} />
|
|
80
|
+
</td>
|
|
81
|
+
<td className="px-2.5 py-1.5 text-theme-text-primary font-mono break-all align-top">
|
|
82
|
+
{value}
|
|
83
|
+
</td>
|
|
84
|
+
<td className="py-1.5 pr-2 align-top">
|
|
85
|
+
<CopyButton text={value} />
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
))}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{/* Long/multiline values: expandable code blocks */}
|
|
94
|
+
{longEntries.map(([key, value]) => {
|
|
95
|
+
const isLarge = value.length > 500
|
|
96
|
+
return (
|
|
97
|
+
<ExpandableSection key={key} title={`${key} (${formatBytes(value.length)})`} defaultExpanded={!isLarge}>
|
|
98
|
+
<pre className="bg-theme-base rounded p-2 text-xs text-theme-text-secondary overflow-x-auto max-h-60 whitespace-pre-wrap">
|
|
99
|
+
{value}
|
|
100
|
+
</pre>
|
|
101
|
+
</ExpandableSection>
|
|
102
|
+
)
|
|
103
|
+
})}
|
|
104
|
+
|
|
105
|
+
{binaryDataKeys.map((key) => (
|
|
106
|
+
<div key={key} className="text-sm text-theme-text-secondary">
|
|
107
|
+
{key} <span className="text-xs">(binary)</span>
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
{dataEntries.length === 0 && binaryDataKeys.length === 0 && (
|
|
111
|
+
<div className="text-sm text-theme-text-tertiary">No data</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</Section>
|
|
115
|
+
|
|
116
|
+
{data.immutable && (
|
|
117
|
+
<div className="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded text-yellow-400 text-sm">
|
|
118
|
+
<AlertTriangle className="w-4 h-4" />
|
|
119
|
+
Immutable ConfigMap
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Clock, Pause } from 'lucide-react'
|
|
2
|
+
import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
3
|
+
import { formatAge, cronToHuman } from '../resource-utils'
|
|
4
|
+
|
|
5
|
+
interface CronJobRendererProps {
|
|
6
|
+
data: any
|
|
7
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
|
|
11
|
+
const status = data.status || {}
|
|
12
|
+
const spec = data.spec || {}
|
|
13
|
+
|
|
14
|
+
// Check for issues or notable states
|
|
15
|
+
const isSuspended = spec.suspend === true
|
|
16
|
+
const hasNeverRun = !status.lastScheduleTime
|
|
17
|
+
|
|
18
|
+
// Calculate time since last success vs last schedule
|
|
19
|
+
const lastScheduleAge = status.lastScheduleTime ? new Date().getTime() - new Date(status.lastScheduleTime).getTime() : 0
|
|
20
|
+
const lastSuccessAge = status.lastSuccessfulTime ? new Date().getTime() - new Date(status.lastSuccessfulTime).getTime() : 0
|
|
21
|
+
const recentFailures = lastScheduleAge > 0 && lastSuccessAge > lastScheduleAge
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
{/* Suspended warning */}
|
|
26
|
+
{isSuspended && (
|
|
27
|
+
<AlertBanner
|
|
28
|
+
variant="warning"
|
|
29
|
+
icon={Pause}
|
|
30
|
+
title="CronJob Suspended"
|
|
31
|
+
message="No new jobs will be scheduled until this CronJob is resumed."
|
|
32
|
+
/>
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
{/* Never run warning */}
|
|
36
|
+
{hasNeverRun && !isSuspended && (
|
|
37
|
+
<AlertBanner
|
|
38
|
+
variant="info"
|
|
39
|
+
title="Never Scheduled"
|
|
40
|
+
message="This CronJob has never run. Check the schedule and starting deadline settings."
|
|
41
|
+
/>
|
|
42
|
+
)}
|
|
43
|
+
|
|
44
|
+
{/* Recent failures warning */}
|
|
45
|
+
{recentFailures && (
|
|
46
|
+
<AlertBanner
|
|
47
|
+
variant="error"
|
|
48
|
+
title="Recent Jobs Failing"
|
|
49
|
+
message={<>Jobs have been scheduled but haven't succeeded recently. Last success: {formatAge(status.lastSuccessfulTime)}. Check job history and pod logs.</>}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
<Section title="Schedule" icon={Clock}>
|
|
54
|
+
<PropertyList>
|
|
55
|
+
<Property label="Schedule" value={spec.schedule} />
|
|
56
|
+
<Property label="Human" value={cronToHuman(spec.schedule)} />
|
|
57
|
+
<Property label="Suspend" value={spec.suspend ? 'Yes' : 'No'} />
|
|
58
|
+
<Property label="Last Schedule" value={status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never'} />
|
|
59
|
+
<Property label="Last Success" value={status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : 'Never'} />
|
|
60
|
+
<Property label="Active Jobs" value={status.active?.length || 0} />
|
|
61
|
+
</PropertyList>
|
|
62
|
+
</Section>
|
|
63
|
+
|
|
64
|
+
<Section title="Configuration">
|
|
65
|
+
<PropertyList>
|
|
66
|
+
<Property label="Concurrency" value={spec.concurrencyPolicy || 'Allow'} />
|
|
67
|
+
<Property label="Starting Deadline" value={spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None'} />
|
|
68
|
+
<Property label="Success History" value={spec.successfulJobsHistoryLimit ?? 3} />
|
|
69
|
+
<Property label="Failed History" value={spec.failedJobsHistoryLimit ?? 1} />
|
|
70
|
+
</PropertyList>
|
|
71
|
+
</Section>
|
|
72
|
+
|
|
73
|
+
{status.active?.length > 0 && (
|
|
74
|
+
<Section title="Active Jobs">
|
|
75
|
+
<div className="space-y-1">
|
|
76
|
+
{status.active.map((job: any) => (
|
|
77
|
+
<div key={job.name} className="text-sm">
|
|
78
|
+
<ResourceLink name={job.name} kind="jobs" namespace={job.namespace || data.metadata?.namespace || ''} onNavigate={onNavigate} />
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</Section>
|
|
83
|
+
)}
|
|
84
|
+
</>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { AlertTriangle, Info, Clock, Target, Server, Hash } from 'lucide-react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Section, PropertyList, Property } from '../../ui/drawer-components'
|
|
4
|
+
import { formatAge } from '../resource-utils'
|
|
5
|
+
import type { NavigateToResource } from '../../../utils/navigation'
|
|
6
|
+
import { kindToPlural } from '../../../utils/navigation'
|
|
7
|
+
|
|
8
|
+
interface EventRendererProps {
|
|
9
|
+
data: any
|
|
10
|
+
onNavigate?: NavigateToResource
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function EventRenderer({ data, onNavigate }: EventRendererProps) {
|
|
14
|
+
const eventType = data.type || 'Normal'
|
|
15
|
+
const isWarning = eventType === 'Warning'
|
|
16
|
+
const reason = data.reason || ''
|
|
17
|
+
const message = data.message || ''
|
|
18
|
+
const count = data.count || 1
|
|
19
|
+
const involvedObject = data.involvedObject || {}
|
|
20
|
+
const source = data.source || {}
|
|
21
|
+
const firstTimestamp = data.firstTimestamp
|
|
22
|
+
const lastTimestamp = data.lastTimestamp || data.metadata?.creationTimestamp
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
{/* Event Type Banner */}
|
|
27
|
+
<div className={clsx(
|
|
28
|
+
'mb-4 p-4 rounded-lg border',
|
|
29
|
+
isWarning
|
|
30
|
+
? 'bg-amber-500/10 border-amber-500/30'
|
|
31
|
+
: 'bg-blue-500/10 border-blue-500/30'
|
|
32
|
+
)}>
|
|
33
|
+
<div className="flex items-start gap-3">
|
|
34
|
+
{isWarning ? (
|
|
35
|
+
<AlertTriangle className="w-5 h-5 text-amber-400 mt-0.5 shrink-0" />
|
|
36
|
+
) : (
|
|
37
|
+
<Info className="w-5 h-5 text-blue-400 mt-0.5 shrink-0" />
|
|
38
|
+
)}
|
|
39
|
+
<div className="flex-1 min-w-0">
|
|
40
|
+
<div className="flex items-center gap-2 mb-1">
|
|
41
|
+
<span className={clsx(
|
|
42
|
+
'px-2 py-0.5 rounded text-xs font-medium',
|
|
43
|
+
isWarning ? 'bg-amber-500/20 text-amber-400' : 'bg-blue-500/20 text-blue-400'
|
|
44
|
+
)}>
|
|
45
|
+
{eventType}
|
|
46
|
+
</span>
|
|
47
|
+
{reason && (
|
|
48
|
+
<span className={clsx(
|
|
49
|
+
'text-sm font-semibold',
|
|
50
|
+
isWarning ? 'text-amber-300' : 'text-blue-300'
|
|
51
|
+
)}>
|
|
52
|
+
{reason}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
{count > 1 && (
|
|
56
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
57
|
+
({count} times)
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
{message && (
|
|
62
|
+
<p className={clsx(
|
|
63
|
+
'text-sm break-all',
|
|
64
|
+
isWarning ? 'text-amber-200/90' : 'text-blue-200/90'
|
|
65
|
+
)}>
|
|
66
|
+
{message}
|
|
67
|
+
</p>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Involved Object */}
|
|
74
|
+
{involvedObject.kind && (
|
|
75
|
+
<Section title="Involved Object" icon={Target} defaultExpanded>
|
|
76
|
+
<div className="space-y-2">
|
|
77
|
+
<div className="flex items-center gap-2">
|
|
78
|
+
<span className="text-theme-text-tertiary text-sm w-24">Kind</span>
|
|
79
|
+
<span className="text-theme-text-primary text-sm font-medium">{involvedObject.kind}</span>
|
|
80
|
+
</div>
|
|
81
|
+
<div className="flex items-center gap-2">
|
|
82
|
+
<span className="text-theme-text-tertiary text-sm w-24">Name</span>
|
|
83
|
+
{onNavigate ? (
|
|
84
|
+
<button
|
|
85
|
+
onClick={() => {
|
|
86
|
+
// Parse API group from apiVersion (format: "group/version" or "v1" for core)
|
|
87
|
+
const apiVersion = involvedObject.apiVersion || ''
|
|
88
|
+
const group = apiVersion.includes('/') ? apiVersion.split('/')[0] : undefined
|
|
89
|
+
onNavigate({
|
|
90
|
+
kind: kindToPlural(involvedObject.kind),
|
|
91
|
+
namespace: involvedObject.namespace || '',
|
|
92
|
+
name: involvedObject.name,
|
|
93
|
+
group,
|
|
94
|
+
})
|
|
95
|
+
}}
|
|
96
|
+
className="text-blue-400 hover:text-blue-300 text-sm font-medium hover:underline"
|
|
97
|
+
>
|
|
98
|
+
{involvedObject.name}
|
|
99
|
+
</button>
|
|
100
|
+
) : (
|
|
101
|
+
<span className="text-theme-text-primary text-sm">{involvedObject.name}</span>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
{involvedObject.namespace && (
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<span className="text-theme-text-tertiary text-sm w-24">Namespace</span>
|
|
107
|
+
<span className="text-theme-text-secondary text-sm">{involvedObject.namespace}</span>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
{involvedObject.uid && (
|
|
111
|
+
<div className="flex items-center gap-2">
|
|
112
|
+
<span className="text-theme-text-tertiary text-sm w-24">UID</span>
|
|
113
|
+
<span className="text-theme-text-tertiary text-xs font-mono">{involvedObject.uid}</span>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
{involvedObject.fieldPath && (
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<span className="text-theme-text-tertiary text-sm w-24">Field</span>
|
|
119
|
+
<span className="text-theme-text-secondary text-sm font-mono">{involvedObject.fieldPath}</span>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</Section>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{/* Timing */}
|
|
127
|
+
<Section title="Timing" icon={Clock} defaultExpanded>
|
|
128
|
+
<PropertyList>
|
|
129
|
+
{count > 1 && <Property label="Count" value={count} />}
|
|
130
|
+
{firstTimestamp && (
|
|
131
|
+
<Property label="First Seen" value={formatAge(firstTimestamp)} />
|
|
132
|
+
)}
|
|
133
|
+
{lastTimestamp && (
|
|
134
|
+
<Property label="Last Seen" value={formatAge(lastTimestamp)} />
|
|
135
|
+
)}
|
|
136
|
+
{firstTimestamp && lastTimestamp && firstTimestamp !== lastTimestamp && (
|
|
137
|
+
<Property label="Duration" value={calculateDuration(firstTimestamp, lastTimestamp)} />
|
|
138
|
+
)}
|
|
139
|
+
</PropertyList>
|
|
140
|
+
</Section>
|
|
141
|
+
|
|
142
|
+
{/* Source */}
|
|
143
|
+
{(source.component || source.host) && (
|
|
144
|
+
<Section title="Source" icon={Server} defaultExpanded={false}>
|
|
145
|
+
<PropertyList>
|
|
146
|
+
{source.component && <Property label="Component" value={source.component} />}
|
|
147
|
+
{source.host && <Property label="Host" value={source.host} />}
|
|
148
|
+
</PropertyList>
|
|
149
|
+
</Section>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* Resource Version Info */}
|
|
153
|
+
{involvedObject.resourceVersion && (
|
|
154
|
+
<Section title="Resource Version" icon={Hash} defaultExpanded={false}>
|
|
155
|
+
<PropertyList>
|
|
156
|
+
<Property label="Object Version" value={involvedObject.resourceVersion} />
|
|
157
|
+
{involvedObject.apiVersion && <Property label="API Version" value={involvedObject.apiVersion} />}
|
|
158
|
+
</PropertyList>
|
|
159
|
+
</Section>
|
|
160
|
+
)}
|
|
161
|
+
</>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function calculateDuration(first: string, last: string): string {
|
|
166
|
+
const firstDate = new Date(first)
|
|
167
|
+
const lastDate = new Date(last)
|
|
168
|
+
const diffMs = lastDate.getTime() - firstDate.getTime()
|
|
169
|
+
|
|
170
|
+
if (diffMs < 1000) return 'instant'
|
|
171
|
+
if (diffMs < 60000) return `${Math.floor(diffMs / 1000)}s`
|
|
172
|
+
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m`
|
|
173
|
+
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ${Math.floor((diffMs % 3600000) / 60000)}m`
|
|
174
|
+
return `${Math.floor(diffMs / 86400000)}d ${Math.floor((diffMs % 86400000) / 3600000)}h`
|
|
175
|
+
}
|