@skyhook-io/k8s-ui 1.5.9 → 1.5.11

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.
Files changed (43) hide show
  1. package/package.json +3 -3
  2. package/src/components/dock/BottomDock.tsx +21 -10
  3. package/src/components/resources/get-pod-phase-display.test.ts +254 -0
  4. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +2 -4
  5. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +3 -2
  6. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -8
  7. package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -1
  8. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
  9. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -1
  10. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
  11. package/src/components/resources/renderers/GatewayClassRenderer.tsx +2 -1
  12. package/src/components/resources/renderers/GatewayRenderer.tsx +5 -4
  13. package/src/components/resources/renderers/IngressClassRenderer.tsx +2 -1
  14. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +2 -1
  15. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +3 -2
  16. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
  18. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +13 -11
  19. package/src/components/resources/renderers/OrderRenderer.tsx +2 -1
  20. package/src/components/resources/renderers/PodRenderer.tsx +23 -2
  21. package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/RolloutRenderer.tsx +2 -1
  23. package/src/components/resources/renderers/SecretRenderer.tsx +15 -6
  24. package/src/components/resources/renderers/aws-capi-cells.tsx +2 -4
  25. package/src/components/resources/renderers/azure-capi-cells.tsx +3 -8
  26. package/src/components/resources/renderers/istio-cells.tsx +3 -2
  27. package/src/components/resources/renderers/karpenter-cells.tsx +1 -1
  28. package/src/components/resources/resource-utils-karpenter.ts +0 -6
  29. package/src/components/resources/resource-utils.ts +108 -0
  30. package/src/components/timeline/shared.tsx +5 -6
  31. package/src/components/topology/K8sResourceNode.tsx +46 -59
  32. package/src/components/topology/TopologyGraph.tsx +3 -3
  33. package/src/components/ui/Tooltip.tsx +73 -40
  34. package/src/components/ui/tooltip-position.test.ts +132 -0
  35. package/src/components/ui/tooltip-position.ts +86 -0
  36. package/src/components/workload/WorkloadView.tsx +6 -6
  37. package/src/types/core.ts +16 -0
  38. package/src/utils/badge-colors.ts +19 -0
  39. package/src/utils/index.ts +2 -0
  40. package/src/utils/validators.test.ts +207 -0
  41. package/src/utils/validators.ts +178 -0
  42. package/src/utils/view-transition.test.ts +81 -0
  43. package/src/utils/view-transition.ts +62 -0
@@ -1,6 +1,7 @@
1
1
  import { ReactNode, useState, useRef, useEffect, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { clsx } from 'clsx'
4
+ import { computeTooltipPosition } from './tooltip-position'
4
5
 
5
6
  // Module-level singleton coordinator: only one Tooltip can be visible
6
7
  // at a time across the whole app. Without this, two Tooltip instances
@@ -45,53 +46,43 @@ export function Tooltip({
45
46
  wrapperStyle,
46
47
  }: TooltipProps) {
47
48
  const [isVisible, setIsVisible] = useState(false)
48
- const [coords, setCoords] = useState({ top: 0, left: 0 })
49
+ // null until measured. Portal renders with visibility:hidden until coords
50
+ // resolve so the user never sees a frame painted at default (0,0).
51
+ const [coords, setCoords] = useState<{ top: number; left: number } | null>(
52
+ null
53
+ )
49
54
  const triggerRef = useRef<HTMLSpanElement>(null)
50
55
  const tooltipRef = useRef<HTMLSpanElement>(null)
51
56
  const timeoutRef = useRef<number | null>(null)
57
+ const rafRef = useRef<number | null>(null)
52
58
 
53
59
  const updatePosition = useCallback(() => {
54
60
  if (!triggerRef.current) return
55
61
 
56
- const rect = triggerRef.current.getBoundingClientRect()
62
+ const triggerRect = triggerRef.current.getBoundingClientRect()
57
63
  const tooltipRect = tooltipRef.current?.getBoundingClientRect()
58
- const tooltipWidth = tooltipRect?.width || 0
59
- const tooltipHeight = tooltipRect?.height || 0
60
-
61
- let top = 0
62
- let left = 0
63
-
64
- switch (position) {
65
- case 'top':
66
- top = rect.top - tooltipHeight - 6
67
- left = rect.left + rect.width / 2 - tooltipWidth / 2
68
- break
69
- case 'bottom':
70
- top = rect.bottom + 6
71
- left = rect.left + rect.width / 2 - tooltipWidth / 2
72
- break
73
- case 'left':
74
- top = rect.top + rect.height / 2 - tooltipHeight / 2
75
- left = rect.left - tooltipWidth - 6
76
- break
77
- case 'right':
78
- top = rect.top + rect.height / 2 - tooltipHeight / 2
79
- left = rect.right + 6
80
- break
81
- }
82
64
 
83
- // Keep tooltip within viewport
84
- const padding = 8
85
- if (left < padding) left = padding
86
- if (left + tooltipWidth > window.innerWidth - padding) {
87
- left = window.innerWidth - tooltipWidth - padding
88
- }
89
- if (top < padding) top = rect.bottom + 6 // flip to bottom
90
- if (top + tooltipHeight > window.innerHeight - padding) {
91
- top = rect.top - tooltipHeight - 6 // flip to top
92
- }
65
+ const next = computeTooltipPosition({
66
+ triggerRect: {
67
+ top: triggerRect.top,
68
+ left: triggerRect.left,
69
+ width: triggerRect.width,
70
+ height: triggerRect.height,
71
+ },
72
+ tooltipSize: {
73
+ width: tooltipRect?.width ?? 0,
74
+ height: tooltipRect?.height ?? 0,
75
+ },
76
+ position,
77
+ viewport: {
78
+ width: window.innerWidth,
79
+ height: window.innerHeight,
80
+ },
81
+ })
93
82
 
94
- setCoords({ top, left })
83
+ if (next) {
84
+ setCoords(next)
85
+ }
95
86
  }, [position])
96
87
 
97
88
  // Stable hide function for the singleton registry — useRef so the
@@ -104,6 +95,7 @@ export function Tooltip({
104
95
  timeoutRef.current = null
105
96
  }
106
97
  setIsVisible(false)
98
+ setCoords(null)
107
99
  }
108
100
 
109
101
  const showTooltip = () => {
@@ -128,12 +120,22 @@ export function Tooltip({
128
120
  activeHide = null
129
121
  }
130
122
  setIsVisible(false)
123
+ setCoords(null)
131
124
  }
132
125
 
133
126
  useEffect(() => {
134
127
  if (isVisible) {
135
- // Small delay to let the tooltip render before measuring
136
- requestAnimationFrame(updatePosition)
128
+ // Second rAF re-centers using the measured tooltip size; without
129
+ // it the first frame centers against size 0 and visibly jumps.
130
+ const id1 = requestAnimationFrame(() => {
131
+ updatePosition()
132
+ const id2 = requestAnimationFrame(updatePosition)
133
+ rafRef.current = id2
134
+ })
135
+ rafRef.current = id1
136
+ return () => {
137
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
138
+ }
137
139
  }
138
140
  }, [isVisible, updatePosition])
139
141
 
@@ -142,6 +144,9 @@ export function Tooltip({
142
144
  if (timeoutRef.current) {
143
145
  clearTimeout(timeoutRef.current)
144
146
  }
147
+ if (rafRef.current !== null) {
148
+ cancelAnimationFrame(rafRef.current)
149
+ }
145
150
  // Clear from singleton registry on unmount — otherwise a Tooltip
146
151
  // that unmounts while visible (e.g. row removed during hover)
147
152
  // would leave a stale entry in activeHide pointing at a torn-down
@@ -165,9 +170,27 @@ export function Tooltip({
165
170
  timeoutRef.current = null
166
171
  }
167
172
  setIsVisible(false)
173
+ setCoords(null)
168
174
  }
169
175
  }, [disabled])
170
176
 
177
+ // Mouseleave doesn't fire when the trigger stays mounted across a
178
+ // navigation and the cursor is still over it; popstate + Escape give
179
+ // a deterministic dismissal path for those cases.
180
+ useEffect(() => {
181
+ if (!isVisible) return
182
+ const onPop = () => hideRef.current()
183
+ const onKey = (e: KeyboardEvent) => {
184
+ if (e.key === 'Escape') hideRef.current()
185
+ }
186
+ window.addEventListener('popstate', onPop)
187
+ window.addEventListener('keydown', onKey)
188
+ return () => {
189
+ window.removeEventListener('popstate', onPop)
190
+ window.removeEventListener('keydown', onKey)
191
+ }
192
+ }, [isVisible])
193
+
171
194
  if (disabled || !content) {
172
195
  return <>{children}</>
173
196
  }
@@ -182,6 +205,10 @@ export function Tooltip({
182
205
  onMouseLeave={hideTooltip}
183
206
  onFocus={showTooltip}
184
207
  onBlur={hideTooltip}
208
+ // pointerdown fires before click, so the tooltip is gone before
209
+ // the trigger's action runs. Required when the child handles
210
+ // navigation/state changes that don't unmount the trigger.
211
+ onPointerDown={hideTooltip}
185
212
  >
186
213
  {children}
187
214
  </span>
@@ -194,8 +221,14 @@ export function Tooltip({
194
221
  'whitespace-nowrap pointer-events-none',
195
222
  className
196
223
  )}
197
- style={{ top: coords.top, left: coords.left }}
224
+ style={{
225
+ top: coords?.top ?? 0,
226
+ left: coords?.left ?? 0,
227
+ // Hide until first measurement so we never paint at (0,0).
228
+ visibility: coords ? 'visible' : 'hidden',
229
+ }}
198
230
  role="tooltip"
231
+ aria-hidden={coords ? undefined : true}
199
232
  >
200
233
  {content}
201
234
  </span>,
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { computeTooltipPosition } from './tooltip-position'
3
+
4
+ const VIEWPORT = { width: 1280, height: 800 }
5
+
6
+ describe('computeTooltipPosition', () => {
7
+ it('returns null when the trigger has no layout (prevents 0,0 flash)', () => {
8
+ expect(
9
+ computeTooltipPosition({
10
+ triggerRect: { top: 0, left: 0, width: 0, height: 0 },
11
+ tooltipSize: { width: 80, height: 24 },
12
+ position: 'top',
13
+ viewport: VIEWPORT,
14
+ })
15
+ ).toBeNull()
16
+ })
17
+
18
+ it('returns a valid position even when the tooltip itself is unmeasured (size 0,0)', () => {
19
+ // Real situation on the very first show: tooltip portal exists but
20
+ // hasn't laid out yet, so its rect is (0,0,0,0). We still need a
21
+ // sane anchor so the next frame's update has something to refine —
22
+ // but it must NOT be (0,0).
23
+ const result = computeTooltipPosition({
24
+ triggerRect: { top: 12, left: 200, width: 80, height: 28 },
25
+ tooltipSize: { width: 0, height: 0 },
26
+ position: 'top',
27
+ viewport: VIEWPORT,
28
+ })
29
+ expect(result).not.toBeNull()
30
+ // Top placement above a near-the-edge trigger flips to bottom; either
31
+ // way the tooltip must not land at the viewport origin.
32
+ expect(result!.top).toBeGreaterThanOrEqual(8)
33
+ expect(result!.left).toBeGreaterThanOrEqual(8)
34
+ })
35
+
36
+ it('centers a top-placed tooltip horizontally above the trigger', () => {
37
+ const result = computeTooltipPosition({
38
+ triggerRect: { top: 200, left: 400, width: 100, height: 40 },
39
+ tooltipSize: { width: 80, height: 24 },
40
+ position: 'top',
41
+ viewport: VIEWPORT,
42
+ })
43
+ expect(result).toEqual({
44
+ // 200 - 24 - 6
45
+ top: 170,
46
+ // 400 + 50 - 40
47
+ left: 410,
48
+ })
49
+ })
50
+
51
+ it('flips a top-placed tooltip to bottom when the trigger is too close to the top edge (nav button case)', () => {
52
+ // Trigger near the top edge: "top" placement computes a negative top,
53
+ // so the helper must flip below.
54
+ const result = computeTooltipPosition({
55
+ triggerRect: { top: 12, left: 200, width: 80, height: 28 },
56
+ tooltipSize: { width: 80, height: 24 },
57
+ position: 'top',
58
+ viewport: VIEWPORT,
59
+ })
60
+ // 12 - 24 - 6 = -18 → flips to 12 + 28 + 6 = 46
61
+ expect(result!.top).toBe(46)
62
+ // Centered: 200 + 40 - 40 = 200
63
+ expect(result!.left).toBe(200)
64
+ })
65
+
66
+ it('clamps a tooltip that would overflow the right edge', () => {
67
+ const result = computeTooltipPosition({
68
+ triggerRect: { top: 100, left: 1240, width: 30, height: 28 },
69
+ tooltipSize: { width: 200, height: 24 },
70
+ position: 'top',
71
+ viewport: VIEWPORT,
72
+ })
73
+ // viewport.width - tooltipWidth - padding = 1280 - 200 - 8
74
+ expect(result!.left).toBe(1072)
75
+ })
76
+
77
+ it('clamps a tooltip that would overflow the left edge', () => {
78
+ const result = computeTooltipPosition({
79
+ triggerRect: { top: 100, left: 4, width: 30, height: 28 },
80
+ tooltipSize: { width: 200, height: 24 },
81
+ position: 'top',
82
+ viewport: VIEWPORT,
83
+ })
84
+ expect(result!.left).toBe(8)
85
+ })
86
+
87
+ it('places a bottom-anchored tooltip below the trigger', () => {
88
+ const result = computeTooltipPosition({
89
+ triggerRect: { top: 200, left: 400, width: 100, height: 40 },
90
+ tooltipSize: { width: 80, height: 24 },
91
+ position: 'bottom',
92
+ viewport: VIEWPORT,
93
+ })
94
+ expect(result!.top).toBe(246)
95
+ })
96
+
97
+ it('places a left-anchored tooltip to the left of the trigger and centers vertically', () => {
98
+ const result = computeTooltipPosition({
99
+ triggerRect: { top: 200, left: 400, width: 100, height: 40 },
100
+ tooltipSize: { width: 80, height: 24 },
101
+ position: 'left',
102
+ viewport: VIEWPORT,
103
+ })
104
+ expect(result).toEqual({
105
+ // 200 + 20 - 12
106
+ top: 208,
107
+ // 400 - 80 - 6
108
+ left: 314,
109
+ })
110
+ })
111
+
112
+ it('places a right-anchored tooltip to the right of the trigger', () => {
113
+ const result = computeTooltipPosition({
114
+ triggerRect: { top: 200, left: 400, width: 100, height: 40 },
115
+ tooltipSize: { width: 80, height: 24 },
116
+ position: 'right',
117
+ viewport: VIEWPORT,
118
+ })
119
+ expect(result!.left).toBe(506)
120
+ })
121
+
122
+ it('respects a custom padding', () => {
123
+ const result = computeTooltipPosition({
124
+ triggerRect: { top: 100, left: 4, width: 30, height: 28 },
125
+ tooltipSize: { width: 200, height: 24 },
126
+ position: 'top',
127
+ viewport: VIEWPORT,
128
+ padding: 20,
129
+ })
130
+ expect(result!.left).toBe(20)
131
+ })
132
+ })
@@ -0,0 +1,86 @@
1
+ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
2
+
3
+ export interface Rect {
4
+ top: number
5
+ left: number
6
+ width: number
7
+ height: number
8
+ }
9
+
10
+ export interface Size {
11
+ width: number
12
+ height: number
13
+ }
14
+
15
+ export interface Viewport {
16
+ width: number
17
+ height: number
18
+ }
19
+
20
+ export interface ComputeTooltipPositionInput {
21
+ triggerRect: Rect
22
+ tooltipSize: Size
23
+ position: TooltipPlacement
24
+ viewport: Viewport
25
+ padding?: number
26
+ }
27
+
28
+ /**
29
+ * Returns null when the trigger has no layout (zero rect at origin) so the
30
+ * caller can render hidden until a real measurement is available, instead
31
+ * of painting at (0, 0).
32
+ */
33
+ export function computeTooltipPosition({
34
+ triggerRect,
35
+ tooltipSize,
36
+ position,
37
+ viewport,
38
+ padding = 8,
39
+ }: ComputeTooltipPositionInput): { top: number; left: number } | null {
40
+ const triggerHasNoLayout =
41
+ triggerRect.width === 0 &&
42
+ triggerRect.height === 0 &&
43
+ triggerRect.top === 0 &&
44
+ triggerRect.left === 0
45
+ if (triggerHasNoLayout) {
46
+ return null
47
+ }
48
+
49
+ const { width: tw, height: th } = tooltipSize
50
+
51
+ let top = 0
52
+ let left = 0
53
+
54
+ switch (position) {
55
+ case 'top':
56
+ top = triggerRect.top - th - 6
57
+ left = triggerRect.left + triggerRect.width / 2 - tw / 2
58
+ break
59
+ case 'bottom':
60
+ top = triggerRect.top + triggerRect.height + 6
61
+ left = triggerRect.left + triggerRect.width / 2 - tw / 2
62
+ break
63
+ case 'left':
64
+ top = triggerRect.top + triggerRect.height / 2 - th / 2
65
+ left = triggerRect.left - tw - 6
66
+ break
67
+ case 'right':
68
+ top = triggerRect.top + triggerRect.height / 2 - th / 2
69
+ left = triggerRect.left + triggerRect.width + 6
70
+ break
71
+ }
72
+
73
+ if (left < padding) left = padding
74
+ if (left + tw > viewport.width - padding) {
75
+ left = viewport.width - tw - padding
76
+ }
77
+ if (top < padding) {
78
+ top = triggerRect.top + triggerRect.height + 6
79
+ }
80
+ if (top + th > viewport.height - padding) {
81
+ top = triggerRect.top - th - 6
82
+ }
83
+ if (top < padding) top = padding
84
+
85
+ return { top, left }
86
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
3
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
4
+ import { startViewTransitionSafe } from '../../utils/view-transition'
4
5
  import { PaneLoader } from '../ui/PaneLoader'
5
6
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
6
7
  import { clsx } from 'clsx'
@@ -212,12 +213,11 @@ export function WorkloadView({
212
213
  }, [kindProp, namespace, name, initialTab])
213
214
 
214
215
  const switchView = useCallback((yaml: boolean) => {
215
- const update = () => flushSync(() => setShowYaml(yaml))
216
- if (document.startViewTransition) {
217
- document.startViewTransition(update)
218
- } else {
219
- setShowYaml(yaml)
220
- }
216
+ // startViewTransitionSafe handles the API-missing fallback AND
217
+ // swallows the InvalidStateError that the API rejects with when
218
+ // a new transition supersedes an in-flight one (rapid clicks).
219
+ // (SKY-833 bug 49)
220
+ startViewTransitionSafe(() => flushSync(() => setShowYaml(yaml)))
221
221
  }, [])
222
222
 
223
223
  const [selectedEventId, setSelectedEventId] = useState<string | null>(null)
package/src/types/core.ts CHANGED
@@ -33,11 +33,27 @@ export interface Capabilities {
33
33
  helmWrite: boolean // Helm write operations (install, upgrade, rollback, uninstall, apply values)
34
34
  nodeWrite: boolean // Node write operations (cordon, uncordon, drain)
35
35
  mcpEnabled: boolean // MCP server is running
36
+ // How / where this Radar binary is running. Optional on the wire so a
37
+ // newer frontend (e.g. radar-hub-web bundling a fresher @skyhook-io/radar-app)
38
+ // doesn't crash against an older backend that hasn't shipped the field yet —
39
+ // consumers should default to { mode: 'local' } when absent.
40
+ deployment?: Deployment
36
41
  resources?: ResourcePermissions // Per-resource-type permissions
37
42
  authEnabled?: boolean // Auth is enabled on the backend
38
43
  username?: string // Authenticated user's username (when auth enabled)
39
44
  }
40
45
 
46
+ // DeploymentMode is the closed set of topologies Radar can run in.
47
+ // `local` is a developer's machine with a kubeconfig (most OSS use).
48
+ // `in-cluster` is a Radar pod inside the cluster, no kubeconfig.
49
+ // `cloud` is in-cluster + tunneled to Radar Cloud's hub — UI suppresses
50
+ // chrome the hub already renders (cluster headline, local-MCP card).
51
+ export type DeploymentMode = 'local' | 'in-cluster' | 'cloud'
52
+
53
+ export interface Deployment {
54
+ mode: DeploymentMode
55
+ }
56
+
41
57
  // Core node kinds that have specific UI handling
42
58
  //
43
59
  // When adding a new kind here, also update:
@@ -53,6 +53,25 @@ export const HELM_STATUS_COLORS: Record<string, string> = {
53
53
  uninstalled: BADGE_SEVERITY_COLORS.neutral,
54
54
  }
55
55
 
56
+ // Cloud instance capacity/priority badges — used by Karpenter NodeClaim and CAPI cloud-provider renderers
57
+ // Keyed by the value as it appears in the resource (spot, on-demand, regular, onDemand)
58
+ export const CAPACITY_TYPE_BADGE: Record<string, string> = {
59
+ spot: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
60
+ 'on-demand': 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
61
+ onDemand: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
62
+ regular: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
63
+ }
64
+
65
+ // AKS-style nodepool mode badges — System (control plane) vs User (workload)
66
+ export const NODEPOOL_MODE_BADGE: Record<string, string> = {
67
+ System: 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
68
+ User: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
69
+ }
70
+
71
+ // Translucent gray badge for "inactive / unknown / pending / unset / disabled" states.
72
+ // The de-facto fallback in renderers when no severity or category applies.
73
+ export const BADGE_INACTIVE = 'bg-gray-500/20 text-gray-400'
74
+
56
75
  // Best practices category colors
57
76
  export const BP_CATEGORY_BADGE: Record<string, string> = {
58
77
  Security: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/40 dark:text-purple-400 dark:border-purple-800/40',
@@ -12,3 +12,5 @@ export * from './api-resources'
12
12
  export * from './skeleton-yaml'
13
13
  export * from './k8s-errors'
14
14
  export * from './parse-go-time'
15
+ export * from './view-transition'
16
+ export * from './validators'