@skyhook-io/radar-app 0.1.1
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 +67 -0
- package/package.json +80 -0
- package/src/App.tsx +1538 -0
- package/src/RadarApp.tsx +145 -0
- package/src/api/apiResources.ts +28 -0
- package/src/api/client.ts +2583 -0
- package/src/api/config.ts +116 -0
- package/src/api/traffic.ts +139 -0
- package/src/components/ConnectionErrorView.tsx +272 -0
- package/src/components/ContextSwitcher.tsx +481 -0
- package/src/components/DebugOverlay.tsx +94 -0
- package/src/components/UserMenu.tsx +87 -0
- package/src/components/audit/AuditSettingsDialog.tsx +162 -0
- package/src/components/audit/AuditView.tsx +123 -0
- package/src/components/cost/CostTrendChart.tsx +388 -0
- package/src/components/cost/CostView.tsx +545 -0
- package/src/components/dock/BottomDock.tsx +96 -0
- package/src/components/dock/DockContext.tsx +11 -0
- package/src/components/dock/LocalTerminalTab.tsx +22 -0
- package/src/components/dock/LogsTab.tsx +26 -0
- package/src/components/dock/NodeTerminalTab.tsx +50 -0
- package/src/components/dock/TerminalTab.tsx +42 -0
- package/src/components/dock/TrafficFlowListTab.tsx +18 -0
- package/src/components/dock/WorkloadLogsTab.tsx +23 -0
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsActions.tsx +1 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
- package/src/components/gitops/ManagedResourcesList.tsx +1 -0
- package/src/components/gitops/SyncCountdown.tsx +1 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/helm/ChartBrowser.tsx +580 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
- package/src/components/helm/HelmView.tsx +475 -0
- package/src/components/helm/InstallWizard.tsx +1060 -0
- package/src/components/helm/ManifestDiffViewer.tsx +91 -0
- package/src/components/helm/ManifestViewer.tsx +61 -0
- package/src/components/helm/OwnedResources.tsx +465 -0
- package/src/components/helm/RevisionHistory.tsx +167 -0
- package/src/components/helm/ValuesDiffPreview.tsx +190 -0
- package/src/components/helm/ValuesViewer.tsx +365 -0
- package/src/components/helm/helm-utils.ts +37 -0
- package/src/components/home/ActivitySummary.tsx +262 -0
- package/src/components/home/CertificateHealthCard.tsx +105 -0
- package/src/components/home/ClusterHealthCard.tsx +483 -0
- package/src/components/home/CostCard.tsx +112 -0
- package/src/components/home/HealthRing.tsx +1 -0
- package/src/components/home/HelmSummary.tsx +129 -0
- package/src/components/home/HomeView.tsx +224 -0
- package/src/components/home/MCPSetupDialog.tsx +417 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
- package/src/components/home/TopologyPreview.tsx +219 -0
- package/src/components/home/TrafficSummary.tsx +154 -0
- package/src/components/logs/JsonLogLine.tsx +1 -0
- package/src/components/logs/LogCore.tsx +2 -0
- package/src/components/logs/LogsViewer.tsx +44 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
- package/src/components/logs/useLogBuffer.ts +2 -0
- package/src/components/logs/useLogSearch.ts +1 -0
- package/src/components/portforward/PortForwardButton.tsx +375 -0
- package/src/components/portforward/PortForwardManager.tsx +871 -0
- package/src/components/resource/PrometheusCharts.tsx +687 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
- package/src/components/resources/ImageFilesystemModal.tsx +745 -0
- package/src/components/resources/PodFilesystemModal.tsx +407 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
- package/src/components/resources/ResourcesView.tsx +190 -0
- package/src/components/resources/drawer-components.tsx +1 -0
- package/src/components/resources/file-browser-utils.ts +35 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/EventRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +1 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/JobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodRenderer.tsx +94 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/VPARenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
- package/src/components/resources/renderers/argo-cells.tsx +1 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
- package/src/components/resources/renderers/eso-cells.tsx +1 -0
- package/src/components/resources/renderers/flux-cells.tsx +1 -0
- package/src/components/resources/renderers/index.ts +91 -0
- package/src/components/resources/renderers/istio-cells.tsx +1 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
- package/src/components/resources/renderers/keda-cells.tsx +1 -0
- package/src/components/resources/renderers/knative-cells.tsx +1 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
- package/src/components/resources/renderers/traefik-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-shared.tsx +1 -0
- package/src/components/resources/renderers/velero-cells.tsx +1 -0
- package/src/components/resources/resource-utils-argo.ts +2 -0
- package/src/components/resources/resource-utils-certmanager.ts +2 -0
- package/src/components/resources/resource-utils-cnpg.ts +2 -0
- package/src/components/resources/resource-utils-eso.ts +2 -0
- package/src/components/resources/resource-utils-flux.ts +2 -0
- package/src/components/resources/resource-utils-istio.ts +2 -0
- package/src/components/resources/resource-utils-karpenter.ts +2 -0
- package/src/components/resources/resource-utils-keda.ts +2 -0
- package/src/components/resources/resource-utils-knative.ts +2 -0
- package/src/components/resources/resource-utils-kyverno.ts +2 -0
- package/src/components/resources/resource-utils-prometheus.ts +2 -0
- package/src/components/resources/resource-utils-traefik.ts +1 -0
- package/src/components/resources/resource-utils-trivy.ts +2 -0
- package/src/components/resources/resource-utils-velero.ts +2 -0
- package/src/components/resources/resource-utils.ts +5 -0
- package/src/components/settings/SettingsDialog.tsx +537 -0
- package/src/components/shared/CreateResourceDialog.tsx +17 -0
- package/src/components/shared/EditableYamlView.tsx +24 -0
- package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
- package/src/components/timeline/DiffViewer.tsx +1 -0
- package/src/components/timeline/TimelineList.tsx +69 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
- package/src/components/timeline/TimelineView.tsx +157 -0
- package/src/components/timeline/shared.tsx +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
- package/src/components/traffic/TrafficFlowList.tsx +415 -0
- package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
- package/src/components/traffic/TrafficGraph.tsx +1546 -0
- package/src/components/traffic/TrafficView.tsx +1213 -0
- package/src/components/traffic/TrafficWizard.tsx +386 -0
- package/src/components/traffic/index.ts +3 -0
- package/src/components/ui/CodeViewer.tsx +8 -0
- package/src/components/ui/CommandPalette.tsx +460 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
- package/src/components/ui/ErrorBoundary.tsx +46 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
- package/src/components/ui/Markdown.tsx +108 -0
- package/src/components/ui/MetricsChart.tsx +1 -0
- package/src/components/ui/NamespaceSelector.tsx +436 -0
- package/src/components/ui/ResourceBar.tsx +1 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
- package/src/components/ui/Toast.tsx +1 -0
- package/src/components/ui/Tooltip.tsx +1 -0
- package/src/components/ui/UpdateNotification.tsx +299 -0
- package/src/components/ui/YamlEditor.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +532 -0
- package/src/context/ConnectionContext.tsx +173 -0
- package/src/context/ContextSwitchContext.tsx +56 -0
- package/src/context/NavCustomization.tsx +62 -0
- package/src/context/ThemeContext.tsx +97 -0
- package/src/contexts/CapabilitiesContext.tsx +130 -0
- package/src/hooks/useAnimatedUnmount.ts +1 -0
- package/src/hooks/useDesktopDownload.ts +41 -0
- package/src/hooks/useEventSource.ts +262 -0
- package/src/hooks/useFavorites.ts +69 -0
- package/src/hooks/useKeyboardShortcuts.tsx +7 -0
- package/src/hooks/useRefreshAnimation.ts +1 -0
- package/src/index.css +243 -0
- package/src/index.ts +17 -0
- package/src/main.tsx +158 -0
- package/src/types/gitops.ts +2 -0
- package/src/types.ts +3 -0
- package/src/utils/animation.ts +2 -0
- package/src/utils/badge-colors.ts +2 -0
- package/src/utils/context-name.ts +2 -0
- package/src/utils/desktop-download.ts +66 -0
- package/src/utils/desktop-open-folder.ts +21 -0
- package/src/utils/format.ts +2 -0
- package/src/utils/log-format.ts +12 -0
- package/src/utils/navigation.ts +23 -0
- package/src/utils/resource-hierarchy.ts +2 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.ts +2 -0
- package/src/utils/traffic-colors.ts +54 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { X, Plus, Trash2 } from 'lucide-react'
|
|
3
|
+
import { useAuditSettings, useUpdateAuditSettings, useAudit } from '../../api/client'
|
|
4
|
+
import type { CheckMeta } from '@skyhook-io/k8s-ui'
|
|
5
|
+
|
|
6
|
+
interface AuditSettingsDialogProps {
|
|
7
|
+
namespaces: string[]
|
|
8
|
+
onClose: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialogProps) {
|
|
12
|
+
const { data: settings } = useAuditSettings()
|
|
13
|
+
const { data: auditData } = useAudit(namespaces)
|
|
14
|
+
const updateSettings = useUpdateAuditSettings()
|
|
15
|
+
const [ignoredNs, setIgnoredNs] = useState<string[]>([])
|
|
16
|
+
const [disabledChecks, setDisabledChecks] = useState<string[]>([])
|
|
17
|
+
const [newNs, setNewNs] = useState('')
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (settings) {
|
|
21
|
+
setIgnoredNs(settings.ignoredNamespaces || [])
|
|
22
|
+
setDisabledChecks(settings.disabledChecks || [])
|
|
23
|
+
}
|
|
24
|
+
}, [settings])
|
|
25
|
+
|
|
26
|
+
// Get all available checks from the audit response
|
|
27
|
+
const allChecks: CheckMeta[] = auditData?.checks
|
|
28
|
+
? Object.values(auditData.checks).sort((a, b) => a.title.localeCompare(b.title))
|
|
29
|
+
: []
|
|
30
|
+
|
|
31
|
+
const addNamespace = () => {
|
|
32
|
+
const ns = newNs.trim()
|
|
33
|
+
if (ns && !ignoredNs.includes(ns)) {
|
|
34
|
+
setIgnoredNs([...ignoredNs, ns])
|
|
35
|
+
setNewNs('')
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const toggleCheck = (checkID: string) => {
|
|
40
|
+
if (disabledChecks.includes(checkID)) {
|
|
41
|
+
setDisabledChecks(disabledChecks.filter(c => c !== checkID))
|
|
42
|
+
} else {
|
|
43
|
+
setDisabledChecks([...disabledChecks, checkID])
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const handleSave = () => {
|
|
48
|
+
updateSettings.mutate(
|
|
49
|
+
{ ignoredNamespaces: ignoredNs, disabledChecks },
|
|
50
|
+
{ onSuccess: () => onClose() },
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
|
56
|
+
<div className="bg-theme-surface rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
57
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-theme-border shrink-0">
|
|
58
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">Audit Settings</h2>
|
|
59
|
+
<button onClick={onClose} className="p-1 rounded-lg hover:bg-theme-hover transition-colors">
|
|
60
|
+
<X className="w-4 h-4 text-theme-text-tertiary" />
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div className="px-5 py-4 overflow-y-auto flex-1">
|
|
65
|
+
{/* Ignored Namespaces */}
|
|
66
|
+
<div className="mb-6">
|
|
67
|
+
<label className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider">
|
|
68
|
+
Ignored Namespaces
|
|
69
|
+
</label>
|
|
70
|
+
<p className="text-xs text-theme-text-tertiary mt-1 mb-3">
|
|
71
|
+
Findings in these namespaces are hidden from all views.
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
<div className="flex flex-col gap-1.5 mb-3">
|
|
75
|
+
{ignoredNs.map(ns => (
|
|
76
|
+
<div key={ns} className="flex items-center justify-between px-3 py-1.5 bg-theme-elevated rounded-lg">
|
|
77
|
+
<span className="text-sm text-theme-text-primary">{ns}</span>
|
|
78
|
+
<button
|
|
79
|
+
onClick={() => setIgnoredNs(ignoredNs.filter(n => n !== ns))}
|
|
80
|
+
className="p-1 rounded hover:bg-theme-hover text-theme-text-tertiary hover:text-red-400 transition-colors"
|
|
81
|
+
>
|
|
82
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
{ignoredNs.length === 0 && (
|
|
87
|
+
<div className="text-xs text-theme-text-tertiary py-2">No namespaces ignored.</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="flex gap-2">
|
|
92
|
+
<input
|
|
93
|
+
type="text"
|
|
94
|
+
value={newNs}
|
|
95
|
+
onChange={e => setNewNs(e.target.value)}
|
|
96
|
+
onKeyDown={e => { if (e.key === 'Enter') addNamespace() }}
|
|
97
|
+
placeholder="Add namespace..."
|
|
98
|
+
className="flex-1 px-3 py-1.5 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
|
|
99
|
+
/>
|
|
100
|
+
<button
|
|
101
|
+
onClick={addNamespace}
|
|
102
|
+
disabled={!newNs.trim()}
|
|
103
|
+
className="px-3 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
104
|
+
>
|
|
105
|
+
<Plus className="w-4 h-4" />
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Disabled Checks */}
|
|
111
|
+
<div>
|
|
112
|
+
<label className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider">
|
|
113
|
+
Enabled Checks
|
|
114
|
+
</label>
|
|
115
|
+
<p className="text-xs text-theme-text-tertiary mt-1 mb-3">
|
|
116
|
+
Uncheck to disable specific checks globally across all views.
|
|
117
|
+
</p>
|
|
118
|
+
|
|
119
|
+
<div className="flex flex-col gap-0.5">
|
|
120
|
+
{allChecks.map(check => {
|
|
121
|
+
const disabled = disabledChecks.includes(check.id)
|
|
122
|
+
return (
|
|
123
|
+
<label
|
|
124
|
+
key={check.id}
|
|
125
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-theme-hover/50 cursor-pointer transition-colors"
|
|
126
|
+
>
|
|
127
|
+
<input
|
|
128
|
+
type="checkbox"
|
|
129
|
+
checked={!disabled}
|
|
130
|
+
onChange={() => toggleCheck(check.id)}
|
|
131
|
+
className="w-4 h-4 rounded border-theme-border text-skyhook-500 focus:ring-skyhook-500"
|
|
132
|
+
/>
|
|
133
|
+
<div className="flex-1 min-w-0">
|
|
134
|
+
<span className="text-sm text-theme-text-primary">{check.title}</span>
|
|
135
|
+
<p className="text-xs text-theme-text-tertiary truncate">{check.description}</p>
|
|
136
|
+
</div>
|
|
137
|
+
</label>
|
|
138
|
+
)
|
|
139
|
+
})}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div className="flex justify-end gap-2 px-5 py-3 border-t border-theme-border shrink-0">
|
|
145
|
+
<button
|
|
146
|
+
onClick={onClose}
|
|
147
|
+
className="px-4 py-1.5 text-sm text-theme-text-secondary hover:text-theme-text-primary bg-theme-elevated hover:bg-theme-hover border border-theme-border rounded-lg transition-colors"
|
|
148
|
+
>
|
|
149
|
+
Cancel
|
|
150
|
+
</button>
|
|
151
|
+
<button
|
|
152
|
+
onClick={handleSave}
|
|
153
|
+
disabled={updateSettings.isPending}
|
|
154
|
+
className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50"
|
|
155
|
+
>
|
|
156
|
+
{updateSettings.isPending ? 'Saving...' : 'Save'}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react'
|
|
2
|
+
import { useAudit, useAuditSettings, useUpdateAuditSettings } from '../../api/client'
|
|
3
|
+
import type { SelectedResource } from '../../types'
|
|
4
|
+
import { AuditFindingsTable } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { ArrowLeft, ClipboardCheck, Loader2, Settings } from 'lucide-react'
|
|
6
|
+
import { AuditSettingsDialog } from './AuditSettingsDialog'
|
|
7
|
+
|
|
8
|
+
interface AuditViewProps {
|
|
9
|
+
namespaces: string[]
|
|
10
|
+
onBack: () => void
|
|
11
|
+
onNavigateToResource: (resource: SelectedResource) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditViewProps) {
|
|
15
|
+
const { data, isLoading, error } = useAudit(namespaces)
|
|
16
|
+
const { data: auditSettings } = useAuditSettings()
|
|
17
|
+
const updateSettings = useUpdateAuditSettings()
|
|
18
|
+
const [showSettings, setShowSettings] = useState(false)
|
|
19
|
+
|
|
20
|
+
const ignoredCount = auditSettings?.ignoredNamespaces?.length ?? 0
|
|
21
|
+
|
|
22
|
+
// Inline hide actions — persist to settings immediately
|
|
23
|
+
const hideCheck = useCallback((checkID: string) => {
|
|
24
|
+
if (!auditSettings) return
|
|
25
|
+
const current = auditSettings.disabledChecks || []
|
|
26
|
+
if (current.includes(checkID)) return
|
|
27
|
+
updateSettings.mutate({ ...auditSettings, disabledChecks: [...current, checkID] })
|
|
28
|
+
}, [auditSettings, updateSettings])
|
|
29
|
+
|
|
30
|
+
const hideCategory = useCallback((category: string) => {
|
|
31
|
+
if (!auditSettings || !data?.checks) return
|
|
32
|
+
const checksInCategory = Object.values(data.checks).filter(c => {
|
|
33
|
+
// Match checks whose findings are in this category
|
|
34
|
+
return data.findings.some(f => f.checkID === c.id && f.category === category)
|
|
35
|
+
}).map(c => c.id)
|
|
36
|
+
const current = auditSettings.disabledChecks || []
|
|
37
|
+
const toAdd = checksInCategory.filter(id => !current.includes(id))
|
|
38
|
+
if (toAdd.length === 0) return
|
|
39
|
+
updateSettings.mutate({ ...auditSettings, disabledChecks: [...current, ...toAdd] })
|
|
40
|
+
}, [auditSettings, data, updateSettings])
|
|
41
|
+
|
|
42
|
+
const hideNamespace = useCallback((ns: string) => {
|
|
43
|
+
if (!auditSettings) return
|
|
44
|
+
const current = auditSettings.ignoredNamespaces || []
|
|
45
|
+
if (current.includes(ns)) return
|
|
46
|
+
updateSettings.mutate({ ...auditSettings, ignoredNamespaces: [...current, ns] })
|
|
47
|
+
}, [auditSettings, updateSettings])
|
|
48
|
+
|
|
49
|
+
if (isLoading) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex-1 flex items-center justify-center">
|
|
52
|
+
<div className="flex flex-col items-center gap-3">
|
|
53
|
+
<Loader2 className="w-6 h-6 animate-spin text-theme-text-tertiary" />
|
|
54
|
+
<span className="text-sm text-theme-text-tertiary">Loading audit data...</span>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (error) {
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex-1 flex items-center justify-center text-theme-text-secondary">
|
|
63
|
+
<p>Failed to load audit data</p>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!data) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex-1 flex items-center justify-center text-theme-text-secondary">
|
|
71
|
+
<p>No audit data available</p>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex-1 flex flex-col min-h-0 p-6 gap-6 overflow-auto">
|
|
78
|
+
{/* Header */}
|
|
79
|
+
<div className="flex items-center gap-4">
|
|
80
|
+
<button
|
|
81
|
+
onClick={onBack}
|
|
82
|
+
className="p-1.5 rounded-lg hover:bg-theme-hover transition-colors"
|
|
83
|
+
>
|
|
84
|
+
<ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
|
|
85
|
+
</button>
|
|
86
|
+
<div className="flex-1">
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
<ClipboardCheck className="w-5 h-5 text-theme-text-secondary" />
|
|
89
|
+
<h1 className="text-lg font-semibold text-theme-text-primary">Cluster Audit</h1>
|
|
90
|
+
</div>
|
|
91
|
+
<p className="text-sm text-theme-text-tertiary mt-1 ml-7">
|
|
92
|
+
Security, reliability, and efficiency checks based on Kubernetes best practices from NSA/CISA guidelines, CIS benchmarks, and industry tools like Polaris and Kubescape.
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
96
|
+
{ignoredCount > 0 && (
|
|
97
|
+
<button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
|
|
98
|
+
)}
|
|
99
|
+
<button
|
|
100
|
+
onClick={() => setShowSettings(true)}
|
|
101
|
+
className="p-2 rounded-lg hover:bg-theme-hover text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
|
|
102
|
+
title="Audit settings"
|
|
103
|
+
>
|
|
104
|
+
<Settings className="w-4 h-4" />
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<AuditFindingsTable
|
|
110
|
+
groups={data.groups}
|
|
111
|
+
checks={data.checks}
|
|
112
|
+
onResourceClick={(kind, namespace, name) =>
|
|
113
|
+
onNavigateToResource({ kind, namespace, name })
|
|
114
|
+
}
|
|
115
|
+
onHideCheck={hideCheck}
|
|
116
|
+
onHideCategory={hideCategory}
|
|
117
|
+
onHideNamespace={hideNamespace}
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
{showSettings && <AuditSettingsDialog namespaces={namespaces} onClose={() => setShowSettings(false)} />}
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { useState, useMemo, useRef, useCallback } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Loader2, TrendingUp } from 'lucide-react'
|
|
4
|
+
import {
|
|
5
|
+
useOpenCostTrend,
|
|
6
|
+
type CostTimeRange,
|
|
7
|
+
type OpenCostTrendSeries,
|
|
8
|
+
} from '../../api/client'
|
|
9
|
+
|
|
10
|
+
const SERIES_COLORS = [
|
|
11
|
+
'#3b82f6', // blue-500
|
|
12
|
+
'#10b981', // emerald-500
|
|
13
|
+
'#f97316', // orange-500
|
|
14
|
+
'#a855f7', // purple-500
|
|
15
|
+
'#ec4899', // pink-500
|
|
16
|
+
'#eab308', // yellow-500
|
|
17
|
+
'#06b6d4', // cyan-500
|
|
18
|
+
'#84cc16', // lime-500
|
|
19
|
+
'#ef4444', // red-500
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const TIME_RANGES: { value: CostTimeRange; label: string }[] = [
|
|
23
|
+
{ value: '6h', label: '6h' },
|
|
24
|
+
{ value: '24h', label: '24h' },
|
|
25
|
+
{ value: '7d', label: '7d' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export function CostTrendChart() {
|
|
29
|
+
const [timeRange, setTimeRange] = useState<CostTimeRange>('24h')
|
|
30
|
+
const { data, isLoading } = useOpenCostTrend(timeRange)
|
|
31
|
+
|
|
32
|
+
if (isLoading) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
35
|
+
<div className="flex items-center justify-center h-[200px] text-theme-text-tertiary">
|
|
36
|
+
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
37
|
+
Loading cost trend...
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!data?.available || !data.series?.length) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
49
|
+
<div className="flex items-center justify-between px-4 py-2.5 border-b border-theme-border">
|
|
50
|
+
<div className="flex items-center gap-2">
|
|
51
|
+
<TrendingUp className="w-4 h-4 text-theme-text-tertiary" />
|
|
52
|
+
<span className="text-xs font-medium text-theme-text-secondary">Cost Trend</span>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="flex items-center gap-1">
|
|
55
|
+
{TIME_RANGES.map(tr => (
|
|
56
|
+
<button
|
|
57
|
+
key={tr.value}
|
|
58
|
+
onClick={() => setTimeRange(tr.value)}
|
|
59
|
+
className={clsx(
|
|
60
|
+
'px-2 py-1 text-xs rounded-md transition-colors',
|
|
61
|
+
timeRange === tr.value
|
|
62
|
+
? 'bg-skyhook-600/20 text-blue-400 font-medium'
|
|
63
|
+
: 'text-theme-text-quaternary hover:text-theme-text-tertiary'
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{tr.label}
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="p-4">
|
|
72
|
+
<StackedAreaChart series={data.series} />
|
|
73
|
+
<ChartLegend series={data.series} />
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
|
|
80
|
+
const svgRef = useRef<SVGSVGElement>(null)
|
|
81
|
+
const [hoverX, setHoverX] = useState<number | null>(null)
|
|
82
|
+
|
|
83
|
+
const width = 1000
|
|
84
|
+
const height = 240
|
|
85
|
+
const marginLeft = 55
|
|
86
|
+
const marginRight = 15
|
|
87
|
+
const marginTop = 8
|
|
88
|
+
const marginBottom = 28
|
|
89
|
+
const plotWidth = width - marginLeft - marginRight
|
|
90
|
+
const plotHeight = height - marginTop - marginBottom
|
|
91
|
+
|
|
92
|
+
// All heavy computation in a single useMemo to avoid hooks-after-early-return
|
|
93
|
+
const chartData = useMemo(() => {
|
|
94
|
+
if (!series.length) return null
|
|
95
|
+
|
|
96
|
+
// Collect all unique timestamps and sort
|
|
97
|
+
const tsSet = new Set<number>()
|
|
98
|
+
for (const s of series) {
|
|
99
|
+
for (const dp of s.dataPoints) {
|
|
100
|
+
tsSet.add(dp.timestamp)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const timestamps = Array.from(tsSet).sort((a, b) => a - b)
|
|
104
|
+
if (timestamps.length < 2) return null
|
|
105
|
+
|
|
106
|
+
const minTs = timestamps[0]
|
|
107
|
+
const maxTs = timestamps[timestamps.length - 1]
|
|
108
|
+
|
|
109
|
+
const seriesLookups = series.map(s => {
|
|
110
|
+
const map = new Map<number, number>()
|
|
111
|
+
for (const dp of s.dataPoints) {
|
|
112
|
+
map.set(dp.timestamp, dp.value)
|
|
113
|
+
}
|
|
114
|
+
return map
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Compute stacked values at each timestamp
|
|
118
|
+
const stacked: number[][] = []
|
|
119
|
+
let maxVal = 0
|
|
120
|
+
for (let si = 0; si < series.length; si++) {
|
|
121
|
+
stacked.push([])
|
|
122
|
+
for (let ti = 0; ti < timestamps.length; ti++) {
|
|
123
|
+
const val = seriesLookups[si].get(timestamps[ti]) ?? 0
|
|
124
|
+
const prev = si > 0 ? stacked[si - 1][ti] : 0
|
|
125
|
+
const cumVal = prev + val
|
|
126
|
+
stacked[si].push(cumVal)
|
|
127
|
+
if (cumVal > maxVal) maxVal = cumVal
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (maxVal === 0) maxVal = 1
|
|
132
|
+
const yMax = maxVal + maxVal * 0.1
|
|
133
|
+
|
|
134
|
+
const toX = (ts: number) => marginLeft + ((ts - minTs) / (maxTs - minTs)) * plotWidth
|
|
135
|
+
const toY = (val: number) => marginTop + plotHeight - (val / yMax) * plotHeight
|
|
136
|
+
|
|
137
|
+
// Y axis ticks
|
|
138
|
+
const tickCount = 4
|
|
139
|
+
const yTicks = Array.from({ length: tickCount + 1 }, (_, i) => {
|
|
140
|
+
const val = (yMax / tickCount) * i
|
|
141
|
+
return { val, y: toY(val), label: formatCostAxis(val) }
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// X axis ticks
|
|
145
|
+
const xTickCount = 6
|
|
146
|
+
const xTicks = Array.from({ length: xTickCount + 1 }, (_, i) => {
|
|
147
|
+
const ts = minTs + ((maxTs - minTs) / xTickCount) * i
|
|
148
|
+
return { ts, x: toX(ts), label: formatTimestamp(ts) }
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// Build stacked area paths
|
|
152
|
+
const paths = series.map((_, si) => {
|
|
153
|
+
const topPoints = timestamps.map((ts, ti) => ({ x: toX(ts), y: toY(stacked[si][ti]) }))
|
|
154
|
+
const bottomPoints = si > 0
|
|
155
|
+
? timestamps.map((ts, ti) => ({ x: toX(ts), y: toY(stacked[si - 1][ti]) }))
|
|
156
|
+
: timestamps.map(ts => ({ x: toX(ts), y: toY(0) }))
|
|
157
|
+
|
|
158
|
+
const topPath = topPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
159
|
+
const bottomPath = [...bottomPoints].reverse().map((p, i) => `${i === 0 ? 'L' : 'L'}${p.x},${p.y}`).join(' ')
|
|
160
|
+
const areaPath = topPath + ' ' + bottomPath + ' Z'
|
|
161
|
+
const linePath = topPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
162
|
+
|
|
163
|
+
return { areaPath, linePath, color: SERIES_COLORS[si % SERIES_COLORS.length] }
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
return { timestamps, stacked, minTs, maxTs, yMax, seriesLookups, toX, toY, yTicks, xTicks, paths }
|
|
167
|
+
}, [series])
|
|
168
|
+
|
|
169
|
+
// Hover data — depends on hoverX + chartData, must be a separate hook (called unconditionally)
|
|
170
|
+
const hoverData = useMemo(() => {
|
|
171
|
+
if (hoverX === null || !chartData) return null
|
|
172
|
+
const { timestamps, minTs, maxTs, seriesLookups, toX } = chartData
|
|
173
|
+
const clampedX = Math.max(marginLeft, Math.min(marginLeft + plotWidth, hoverX))
|
|
174
|
+
const frac = (clampedX - marginLeft) / plotWidth
|
|
175
|
+
const ts = minTs + frac * (maxTs - minTs)
|
|
176
|
+
|
|
177
|
+
let closestTi = 0
|
|
178
|
+
let closestDist = Infinity
|
|
179
|
+
for (let ti = 0; ti < timestamps.length; ti++) {
|
|
180
|
+
const dist = Math.abs(timestamps[ti] - ts)
|
|
181
|
+
if (dist < closestDist) {
|
|
182
|
+
closestDist = dist
|
|
183
|
+
closestTi = ti
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const closestTs = timestamps[closestTi]
|
|
188
|
+
let total = 0
|
|
189
|
+
const points = series.map((s, si) => {
|
|
190
|
+
const val = seriesLookups[si].get(closestTs) ?? 0
|
|
191
|
+
total += val
|
|
192
|
+
return { namespace: s.namespace, value: val, color: SERIES_COLORS[si % SERIES_COLORS.length] }
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
return { ts: closestTs, x: toX(closestTs), total, points }
|
|
196
|
+
}, [hoverX, chartData, series])
|
|
197
|
+
|
|
198
|
+
const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
|
|
199
|
+
const svg = svgRef.current
|
|
200
|
+
if (!svg) return
|
|
201
|
+
const ctm = svg.getScreenCTM()
|
|
202
|
+
if (!ctm) return
|
|
203
|
+
setHoverX((e.clientX - ctm.e) / ctm.a)
|
|
204
|
+
}, [])
|
|
205
|
+
|
|
206
|
+
// Early return AFTER all hooks
|
|
207
|
+
if (!chartData) return null
|
|
208
|
+
|
|
209
|
+
const { yTicks, xTicks, paths } = chartData
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div className="relative">
|
|
213
|
+
<svg
|
|
214
|
+
ref={svgRef}
|
|
215
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
216
|
+
className="w-full"
|
|
217
|
+
preserveAspectRatio="xMidYMid meet"
|
|
218
|
+
>
|
|
219
|
+
{/* Grid lines */}
|
|
220
|
+
{yTicks.map((tick, i) => (
|
|
221
|
+
<line
|
|
222
|
+
key={`grid-${i}`}
|
|
223
|
+
x1={marginLeft} y1={tick.y}
|
|
224
|
+
x2={width - marginRight} y2={tick.y}
|
|
225
|
+
stroke="currentColor"
|
|
226
|
+
className="text-theme-border/30"
|
|
227
|
+
strokeWidth="1"
|
|
228
|
+
strokeDasharray={i === 0 ? undefined : '4 4'}
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
|
|
232
|
+
{/* Y axis labels */}
|
|
233
|
+
{yTicks.map((tick, i) => (
|
|
234
|
+
<text
|
|
235
|
+
key={`ylabel-${i}`}
|
|
236
|
+
x={marginLeft - 6}
|
|
237
|
+
y={tick.y + 4}
|
|
238
|
+
textAnchor="end"
|
|
239
|
+
className="fill-theme-text-secondary"
|
|
240
|
+
fontSize="10"
|
|
241
|
+
fontFamily="ui-monospace, monospace"
|
|
242
|
+
>
|
|
243
|
+
{tick.label}
|
|
244
|
+
</text>
|
|
245
|
+
))}
|
|
246
|
+
|
|
247
|
+
{/* X axis labels */}
|
|
248
|
+
{xTicks.map((tick, i) => (
|
|
249
|
+
<text
|
|
250
|
+
key={`xlabel-${i}`}
|
|
251
|
+
x={tick.x}
|
|
252
|
+
y={height - 4}
|
|
253
|
+
textAnchor="middle"
|
|
254
|
+
className="fill-theme-text-secondary"
|
|
255
|
+
fontSize="10"
|
|
256
|
+
fontFamily="ui-monospace, monospace"
|
|
257
|
+
>
|
|
258
|
+
{tick.label}
|
|
259
|
+
</text>
|
|
260
|
+
))}
|
|
261
|
+
|
|
262
|
+
{/* Stacked area fills (render bottom to top) */}
|
|
263
|
+
{paths.map((p, i) => (
|
|
264
|
+
<path
|
|
265
|
+
key={`area-${i}`}
|
|
266
|
+
d={p.areaPath}
|
|
267
|
+
fill={p.color + '33'}
|
|
268
|
+
/>
|
|
269
|
+
))}
|
|
270
|
+
|
|
271
|
+
{/* Lines (top edges of each area) */}
|
|
272
|
+
{paths.map((p, i) => (
|
|
273
|
+
<path
|
|
274
|
+
key={`line-${i}`}
|
|
275
|
+
d={p.linePath}
|
|
276
|
+
fill="none"
|
|
277
|
+
stroke={p.color}
|
|
278
|
+
strokeWidth="1.5"
|
|
279
|
+
strokeLinejoin="round"
|
|
280
|
+
/>
|
|
281
|
+
))}
|
|
282
|
+
|
|
283
|
+
{/* Hover crosshair */}
|
|
284
|
+
{hoverData && (
|
|
285
|
+
<line
|
|
286
|
+
x1={hoverData.x} y1={marginTop}
|
|
287
|
+
x2={hoverData.x} y2={marginTop + plotHeight}
|
|
288
|
+
stroke="currentColor"
|
|
289
|
+
className="text-theme-text-tertiary"
|
|
290
|
+
strokeWidth="1"
|
|
291
|
+
strokeDasharray="4 4"
|
|
292
|
+
/>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{/* Mouse event overlay */}
|
|
296
|
+
<rect
|
|
297
|
+
x={marginLeft} y={marginTop}
|
|
298
|
+
width={plotWidth} height={plotHeight}
|
|
299
|
+
fill="transparent"
|
|
300
|
+
style={{ cursor: 'crosshair' }}
|
|
301
|
+
onMouseMove={handleMouseMove}
|
|
302
|
+
onMouseLeave={() => setHoverX(null)}
|
|
303
|
+
/>
|
|
304
|
+
</svg>
|
|
305
|
+
|
|
306
|
+
{/* Tooltip */}
|
|
307
|
+
{hoverData && (
|
|
308
|
+
<div
|
|
309
|
+
className="absolute top-0 pointer-events-none z-10"
|
|
310
|
+
style={{
|
|
311
|
+
left: `${(hoverData.x / width) * 100}%`,
|
|
312
|
+
transform: hoverData.x > width * 0.65 ? 'translateX(calc(-100% - 12px))' : 'translateX(12px)',
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
<div className="bg-theme-surface border border-theme-border rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap">
|
|
316
|
+
<div className="text-theme-text-tertiary mb-1.5 font-mono">
|
|
317
|
+
{new Date(hoverData.ts * 1000).toLocaleString([], {
|
|
318
|
+
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
319
|
+
})}
|
|
320
|
+
</div>
|
|
321
|
+
{hoverData.points
|
|
322
|
+
.filter(p => p.value > 0)
|
|
323
|
+
.sort((a, b) => b.value - a.value)
|
|
324
|
+
.map((p, i) => (
|
|
325
|
+
<div key={i} className="flex items-center gap-2 py-0.5">
|
|
326
|
+
<div
|
|
327
|
+
className="w-2 h-2 rounded-full shrink-0"
|
|
328
|
+
style={{ backgroundColor: p.color }}
|
|
329
|
+
/>
|
|
330
|
+
<span className="text-theme-text-secondary">{p.namespace}</span>
|
|
331
|
+
<span className="text-theme-text-primary font-semibold ml-auto pl-3 tabular-nums">
|
|
332
|
+
{formatCostTooltip(p.value)}
|
|
333
|
+
</span>
|
|
334
|
+
</div>
|
|
335
|
+
))}
|
|
336
|
+
<div className="border-t border-theme-border/50 mt-1 pt-1 flex justify-between text-theme-text-primary font-semibold">
|
|
337
|
+
<span>Total</span>
|
|
338
|
+
<span className="tabular-nums">{formatCostTooltip(hoverData.total)}</span>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function ChartLegend({ series }: { series: OpenCostTrendSeries[] }) {
|
|
348
|
+
return (
|
|
349
|
+
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
|
350
|
+
{series.map((s, i) => (
|
|
351
|
+
<div key={s.namespace} className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
352
|
+
<div
|
|
353
|
+
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
354
|
+
style={{ backgroundColor: SERIES_COLORS[i % SERIES_COLORS.length] }}
|
|
355
|
+
/>
|
|
356
|
+
<span>{s.namespace}</span>
|
|
357
|
+
</div>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function formatCostAxis(value: number): string {
|
|
364
|
+
if (value >= 1000) return `$${(value / 1000).toFixed(0)}k`
|
|
365
|
+
if (value >= 1) return `$${value.toFixed(1)}`
|
|
366
|
+
if (value >= 0.01) return `$${value.toFixed(2)}`
|
|
367
|
+
if (value > 0) return `$${value.toFixed(3)}`
|
|
368
|
+
return '$0'
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function formatCostTooltip(value: number): string {
|
|
372
|
+
if (value >= 1000) return `$${(value / 1000).toFixed(1)}k/hr`
|
|
373
|
+
if (value >= 1) return `$${value.toFixed(2)}/hr`
|
|
374
|
+
if (value >= 0.01) return `$${value.toFixed(3)}/hr`
|
|
375
|
+
if (value > 0) return `$${value.toFixed(4)}/hr`
|
|
376
|
+
return '$0.00/hr'
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function formatTimestamp(unix: number): string {
|
|
380
|
+
const d = new Date(unix * 1000)
|
|
381
|
+
const now = new Date()
|
|
382
|
+
const diffHours = (now.getTime() - d.getTime()) / (1000 * 60 * 60)
|
|
383
|
+
// Show date+time for ranges > 24h, just time otherwise
|
|
384
|
+
if (diffHours > 36) {
|
|
385
|
+
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
386
|
+
}
|
|
387
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
388
|
+
}
|