@skyhook-io/k8s-ui 1.2.4 → 1.3.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 +3 -2
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/LocalTerminalTab.tsx +1 -1
- package/src/components/dock/NodeTerminalTab.tsx +1 -1
- package/src/components/dock/TerminalTab.tsx +2 -2
- package/src/components/gitops/GitOpsActions.tsx +12 -11
- package/src/components/gitops/GitOpsStatusBadge.tsx +6 -6
- package/src/components/logs/LogCore.tsx +8 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +29 -5
- package/src/components/logs/useLogStream.ts +20 -0
- package/src/components/resources/ResourcesSidebar.tsx +10 -10
- package/src/components/resources/ResourcesView.tsx +49 -48
- package/src/components/resources/renderers/AlertRenderer.tsx +2 -2
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -3
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -3
- package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -2
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +3 -3
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +3 -3
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +1 -1
- package/src/components/resources/renderers/EventRenderer.tsx +1 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -1
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +5 -5
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +3 -3
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
- package/src/components/resources/renderers/HPARenderer.tsx +1 -1
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +5 -5
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -1
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -1
- package/src/components/resources/renderers/IngressRenderer.tsx +1 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +10 -10
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +2 -2
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -2
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +6 -6
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -5
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +5 -5
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +4 -4
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -3
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -3
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +5 -5
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +5 -5
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +4 -4
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +8 -8
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +3 -3
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +2 -2
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +6 -6
- package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +4 -4
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -6
- package/src/components/resources/renderers/NodeRenderer.tsx +5 -5
- package/src/components/resources/renderers/OrderRenderer.tsx +3 -3
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodRenderer.tsx +10 -10
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -3
- package/src/components/resources/renderers/RoleRenderer.tsx +8 -8
- package/src/components/resources/renderers/RolloutRenderer.tsx +1 -1
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +2 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +3 -3
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -1
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +3 -3
- package/src/components/resources/renderers/ServiceRenderer.tsx +1 -1
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +5 -5
- package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -1
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +6 -6
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +5 -5
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +6 -6
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -1
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +10 -10
- package/src/components/resources/renderers/WorkflowRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkloadRenderer.tsx +1 -1
- package/src/components/resources/renderers/argo-cells.tsx +3 -3
- package/src/components/resources/renderers/certmanager-cells.tsx +6 -6
- package/src/components/resources/renderers/cnpg-cells.tsx +5 -5
- package/src/components/resources/renderers/eso-cells.tsx +4 -4
- package/src/components/resources/renderers/flux-cells.tsx +6 -6
- package/src/components/resources/renderers/istio-cells.tsx +9 -9
- package/src/components/resources/renderers/karpenter-cells.tsx +3 -3
- package/src/components/resources/renderers/keda-cells.tsx +2 -2
- package/src/components/resources/renderers/knative-cells.tsx +2 -2
- package/src/components/resources/renderers/kyverno-cells.tsx +3 -3
- package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
- package/src/components/resources/renderers/trivy-cells.tsx +4 -4
- package/src/components/resources/renderers/velero-cells.tsx +4 -4
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +17 -17
- package/src/components/shared/ResourceRendererDispatch.tsx +8 -7
- package/src/components/topology/K8sResourceNode.tsx +5 -5
- package/src/components/topology/TopologyControls.tsx +2 -2
- package/src/components/topology/TopologyGraph.tsx +89 -23
- package/src/components/topology/topology.css +1 -1
- package/src/components/ui/Badge.tsx +178 -0
- package/src/components/ui/HealthRing.tsx +70 -35
- package/src/components/ui/MetricsChart.tsx +2 -2
- package/src/components/ui/drawer-components.tsx +12 -31
- package/src/components/workload/ResourceDetailDrawer.tsx +4 -4
- package/src/components/workload/WorkloadView.tsx +10 -10
- package/src/theme/components.css +195 -0
- package/src/theme/tailwind-theme.css +13 -13
- package/src/theme/variables.css +46 -29
- package/src/types/core.ts +2 -0
- package/src/utils/badge-colors.ts +54 -205
- package/src/utils/log-format.ts +0 -8
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { clsx } from 'clsx'
|
|
2
|
+
import { SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
2
3
|
import {
|
|
3
4
|
getPodStatus,
|
|
4
5
|
getWorkloadStatus,
|
|
@@ -550,9 +551,9 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
550
551
|
// Contour HTTPProxy
|
|
551
552
|
if (k === 'httpproxies') {
|
|
552
553
|
const s = getHTTPProxyStatus(data)
|
|
553
|
-
if (s.status === 'healthy') return { text: s.label, color:
|
|
554
|
-
if (s.status === 'unhealthy') return { text: s.label, color:
|
|
555
|
-
if (s.status === 'degraded') return { text: s.label, color:
|
|
554
|
+
if (s.status === 'healthy') return { text: s.label, color: SEVERITY_BADGE.success }
|
|
555
|
+
if (s.status === 'unhealthy') return { text: s.label, color: SEVERITY_BADGE.error }
|
|
556
|
+
if (s.status === 'degraded') return { text: s.label, color: SEVERITY_BADGE.warning }
|
|
556
557
|
return null
|
|
557
558
|
}
|
|
558
559
|
|
|
@@ -587,9 +588,9 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
587
588
|
const isWarning = warningPhases.includes(phase)
|
|
588
589
|
return {
|
|
589
590
|
text: phase,
|
|
590
|
-
color: isHealthy ?
|
|
591
|
-
isWarning ?
|
|
592
|
-
|
|
591
|
+
color: isHealthy ? SEVERITY_BADGE.success :
|
|
592
|
+
isWarning ? SEVERITY_BADGE.warning :
|
|
593
|
+
SEVERITY_BADGE.error
|
|
593
594
|
}
|
|
594
595
|
}
|
|
595
596
|
|
|
@@ -601,7 +602,7 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
601
602
|
const isReady = readyCondition.status === 'True'
|
|
602
603
|
return {
|
|
603
604
|
text: isReady ? 'Ready' : 'Not Ready',
|
|
604
|
-
color: isReady ?
|
|
605
|
+
color: isReady ? SEVERITY_BADGE.success : SEVERITY_BADGE.warning
|
|
605
606
|
}
|
|
606
607
|
}
|
|
607
608
|
}
|
|
@@ -317,13 +317,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
317
317
|
<div
|
|
318
318
|
className={clsx(
|
|
319
319
|
'flex items-center gap-2 px-4 py-2 rounded-full',
|
|
320
|
-
'
|
|
321
|
-
'shadow-lg shadow-
|
|
322
|
-
selected && 'ring-2 ring-
|
|
320
|
+
'selection border border-skyhook-500/30',
|
|
321
|
+
'shadow-lg shadow-skyhook-500/20',
|
|
322
|
+
selected && 'ring-2 ring-skyhook-400'
|
|
323
323
|
)}
|
|
324
324
|
>
|
|
325
325
|
<span className="topology-icon topology-icon-internet" style={{ width: 20, height: 20 }} />
|
|
326
|
-
<span className="text-sm font-medium text-
|
|
326
|
+
<span className="text-sm font-medium text-skyhook-300">Internet</span>
|
|
327
327
|
<span className="w-2 h-2 rounded-full bg-green-500" />
|
|
328
328
|
</div>
|
|
329
329
|
<Handle
|
|
@@ -347,7 +347,7 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
347
347
|
className={clsx(
|
|
348
348
|
'relative rounded-lg overflow-hidden',
|
|
349
349
|
'bg-theme-surface topology-node-card',
|
|
350
|
-
selected && 'ring-2 ring-
|
|
350
|
+
selected && 'ring-2 ring-skyhook-400',
|
|
351
351
|
isSmallNode && 'opacity-90',
|
|
352
352
|
// Status bar via CSS pseudo-element (defined in index.css)
|
|
353
353
|
(status === 'healthy' || status === 'unknown') && 'topology-node-status-bar',
|
|
@@ -40,7 +40,7 @@ export function TopologyControls({
|
|
|
40
40
|
onClick={() => onViewModeChange('resources')}
|
|
41
41
|
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
|
42
42
|
viewMode === 'resources'
|
|
43
|
-
? 'bg-
|
|
43
|
+
? 'bg-skyhook-600 text-white'
|
|
44
44
|
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
|
|
45
45
|
}`}
|
|
46
46
|
>
|
|
@@ -50,7 +50,7 @@ export function TopologyControls({
|
|
|
50
50
|
onClick={() => onViewModeChange('traffic')}
|
|
51
51
|
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
|
52
52
|
viewMode === 'traffic'
|
|
53
|
-
? 'bg-
|
|
53
|
+
? 'bg-skyhook-600 text-white'
|
|
54
54
|
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
|
|
55
55
|
}`}
|
|
56
56
|
>
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type Node,
|
|
13
13
|
type Edge,
|
|
14
14
|
type NodeTypes,
|
|
15
|
+
type NodeChange,
|
|
15
16
|
type Viewport,
|
|
16
17
|
BackgroundVariant,
|
|
17
18
|
MarkerType,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
import '@xyflow/react/dist/style.css'
|
|
20
21
|
import { toCanvas } from 'html-to-image'
|
|
21
22
|
|
|
22
|
-
import { AlertTriangle, Download, Loader2, RotateCw, Shield } from 'lucide-react'
|
|
23
|
+
import { AlertTriangle, Download, Loader2, Pause, Play, RotateCw, Shield } from 'lucide-react'
|
|
23
24
|
import { useToast } from '../ui/Toast'
|
|
24
25
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
25
26
|
|
|
@@ -156,6 +157,8 @@ interface TopologyGraphProps {
|
|
|
156
157
|
selectedNodeId?: string
|
|
157
158
|
/** Show image export button in controls. Default: true */
|
|
158
159
|
showExportButton?: boolean
|
|
160
|
+
paused?: boolean
|
|
161
|
+
onTogglePause?: () => void
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
export function TopologyGraph({
|
|
@@ -166,10 +169,22 @@ export function TopologyGraph({
|
|
|
166
169
|
onNodeClick,
|
|
167
170
|
selectedNodeId,
|
|
168
171
|
showExportButton = true,
|
|
172
|
+
paused = false,
|
|
173
|
+
onTogglePause,
|
|
169
174
|
}: TopologyGraphProps) {
|
|
170
175
|
const isTrafficView = viewMode === 'traffic'
|
|
171
|
-
const [nodes, setNodes,
|
|
176
|
+
const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
|
|
172
177
|
const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[])
|
|
178
|
+
|
|
179
|
+
// Wrap onNodesChange to track user-dragged positions so they survive re-layouts.
|
|
180
|
+
const onNodesChange = useCallback((changes: NodeChange<Node>[]) => {
|
|
181
|
+
onNodesChangeBase(changes)
|
|
182
|
+
for (const change of changes) {
|
|
183
|
+
if (change.type === 'position' && change.position) {
|
|
184
|
+
savedPositionsRef.current.set(change.id, change.position)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}, [onNodesChangeBase])
|
|
173
188
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
|
174
189
|
const [expandedPodGroups, setExpandedPodGroups] = useState<Set<string>>(new Set())
|
|
175
190
|
const [layoutError, setLayoutError] = useState<string | null>(null)
|
|
@@ -177,6 +192,10 @@ export function TopologyGraph({
|
|
|
177
192
|
const [isExporting, setIsExporting] = useState(false)
|
|
178
193
|
const prevStructureRef = useRef<string>('')
|
|
179
194
|
const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
|
|
195
|
+
// Saved node positions for preservation across topology updates.
|
|
196
|
+
// Prevents every cluster change from re-running a full ELK layout (which shifts all nodes).
|
|
197
|
+
const savedPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
|
|
198
|
+
const prevRetryCountRef = useRef(0)
|
|
180
199
|
|
|
181
200
|
// Toggle group collapse
|
|
182
201
|
const handleToggleCollapse = useCallback((groupId: string) => {
|
|
@@ -373,6 +392,7 @@ export function TopologyGraph({
|
|
|
373
392
|
setNodes([])
|
|
374
393
|
setEdges([])
|
|
375
394
|
prevStructureRef.current = ''
|
|
395
|
+
savedPositionsRef.current.clear() // Clear on context switch / topology reset
|
|
376
396
|
return
|
|
377
397
|
}
|
|
378
398
|
|
|
@@ -382,6 +402,20 @@ export function TopologyGraph({
|
|
|
382
402
|
return
|
|
383
403
|
}
|
|
384
404
|
|
|
405
|
+
// Detect explicit re-layout request (Retry button) — clear saved positions so
|
|
406
|
+
// ELK computes a fresh layout from scratch instead of preserving old positions.
|
|
407
|
+
const isRetry = layoutRetryCount !== prevRetryCountRef.current
|
|
408
|
+
if (isRetry) {
|
|
409
|
+
prevRetryCountRef.current = layoutRetryCount
|
|
410
|
+
savedPositionsRef.current.clear()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// For the initial layout (no saved positions yet) run ELK for all nodes.
|
|
414
|
+
// For subsequent updates, preserve existing positions — only new nodes get
|
|
415
|
+
// ELK-computed positions. This prevents the whole graph from shifting every
|
|
416
|
+
// time the cluster adds a new resource.
|
|
417
|
+
const isInitialLayout = savedPositionsRef.current.size === 0
|
|
418
|
+
|
|
385
419
|
prevStructureRef.current = structureKey
|
|
386
420
|
|
|
387
421
|
// Increment version to invalidate any previous in-flight layout
|
|
@@ -418,8 +452,29 @@ export function TopologyGraph({
|
|
|
418
452
|
}
|
|
419
453
|
setLayoutError(null)
|
|
420
454
|
|
|
455
|
+
// Preserve positions for nodes that already have a saved position (i.e. were
|
|
456
|
+
// present in a previous layout). New nodes use the ELK-computed position.
|
|
457
|
+
// This prevents the whole graph from shifting every time the topology changes.
|
|
458
|
+
// isInitialLayout is captured in the outer effect scope.
|
|
459
|
+
const positionedNodes = isInitialLayout
|
|
460
|
+
? layoutedNodes
|
|
461
|
+
: layoutedNodes.map(node => {
|
|
462
|
+
const saved = savedPositionsRef.current.get(node.id)
|
|
463
|
+
return saved ? { ...node, position: saved } : node
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// Update saved positions: add/overwrite with positions from this layout run.
|
|
467
|
+
// Remove stale entries for nodes no longer in the topology.
|
|
468
|
+
const currentIds = new Set(positionedNodes.map(n => n.id))
|
|
469
|
+
for (const id of savedPositionsRef.current.keys()) {
|
|
470
|
+
if (!currentIds.has(id)) savedPositionsRef.current.delete(id)
|
|
471
|
+
}
|
|
472
|
+
for (const node of positionedNodes) {
|
|
473
|
+
savedPositionsRef.current.set(node.id, node.position)
|
|
474
|
+
}
|
|
475
|
+
|
|
421
476
|
// Add expand/collapse handlers to pod-related nodes
|
|
422
|
-
const nodesWithHandlers =
|
|
477
|
+
const nodesWithHandlers = positionedNodes.map(node => {
|
|
423
478
|
const isPodGroup = node.data?.kind === 'PodGroup'
|
|
424
479
|
const nodeData = node.data?.nodeData as Record<string, unknown> | undefined
|
|
425
480
|
const expandedFromGroup = nodeData?.expandedFromGroup as string | undefined
|
|
@@ -622,8 +677,17 @@ export function TopologyGraph({
|
|
|
622
677
|
showInteractive={false}
|
|
623
678
|
>
|
|
624
679
|
{showExportButton && <ExportImageButton onExportingChange={setIsExporting} />}
|
|
680
|
+
{onTogglePause && (
|
|
681
|
+
<button
|
|
682
|
+
className={`react-flow__controls-button ${paused ? 'text-amber-400' : ''}`}
|
|
683
|
+
onClick={onTogglePause}
|
|
684
|
+
title={paused ? 'Resume live updates' : 'Pause live updates'}
|
|
685
|
+
>
|
|
686
|
+
{paused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
|
|
687
|
+
</button>
|
|
688
|
+
)}
|
|
625
689
|
</Controls>
|
|
626
|
-
<ViewportController
|
|
690
|
+
<ViewportController viewMode={viewMode} layoutRetryCount={layoutRetryCount} />
|
|
627
691
|
</ReactFlow>
|
|
628
692
|
</ReactFlowProvider>
|
|
629
693
|
)
|
|
@@ -818,7 +882,7 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
|
|
|
818
882
|
<button
|
|
819
883
|
key={mode}
|
|
820
884
|
onClick={() => setCaptureMode(mode)}
|
|
821
|
-
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${captureMode === mode ? '
|
|
885
|
+
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${captureMode === mode ? 'btn-brand' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
|
|
822
886
|
>
|
|
823
887
|
{mode === 'full' ? 'Entire graph' : 'Visible area'}
|
|
824
888
|
</button>
|
|
@@ -832,7 +896,7 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
|
|
|
832
896
|
<button
|
|
833
897
|
key={f}
|
|
834
898
|
onClick={() => setFormat(f)}
|
|
835
|
-
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${format === f ? '
|
|
899
|
+
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${format === f ? 'btn-brand' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
|
|
836
900
|
>
|
|
837
901
|
{FORMAT_LABELS[f]}
|
|
838
902
|
</button>
|
|
@@ -880,7 +944,7 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
|
|
|
880
944
|
<button
|
|
881
945
|
onClick={doExport}
|
|
882
946
|
disabled={exporting}
|
|
883
|
-
className="flex-1 px-3 py-1.5 text-sm font-medium
|
|
947
|
+
className="flex-1 px-3 py-1.5 text-sm font-medium btn-brand rounded flex items-center justify-center gap-1.5"
|
|
884
948
|
>
|
|
885
949
|
{exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
|
|
886
950
|
Export
|
|
@@ -904,10 +968,17 @@ const VIEWPORT_ANIMATION_DURATION = 400
|
|
|
904
968
|
|
|
905
969
|
// Inner component to handle animated viewport transitions and zoom-based CSS variables
|
|
906
970
|
// Must be inside ReactFlow to use useReactFlow hook
|
|
907
|
-
function ViewportController({
|
|
971
|
+
function ViewportController({
|
|
972
|
+
viewMode,
|
|
973
|
+
layoutRetryCount,
|
|
974
|
+
}: {
|
|
975
|
+
viewMode: string
|
|
976
|
+
layoutRetryCount: number
|
|
977
|
+
}) {
|
|
908
978
|
const { fitView, zoomIn, zoomOut, setViewport, getViewport } = useReactFlow()
|
|
909
979
|
const nodes = useNodes() // Reactive hook to watch node changes
|
|
910
|
-
const
|
|
980
|
+
const prevViewModeRef = useRef<string>(viewMode)
|
|
981
|
+
const prevRetryCountRef = useRef(layoutRetryCount)
|
|
911
982
|
const prevNodesLengthRef = useRef(0)
|
|
912
983
|
|
|
913
984
|
// Topology keyboard shortcuts
|
|
@@ -978,34 +1049,29 @@ function ViewportController({ structureKey }: { structureKey: string }) {
|
|
|
978
1049
|
updateZoomOffset(getViewport())
|
|
979
1050
|
}, [updateZoomOffset, getViewport])
|
|
980
1051
|
|
|
981
|
-
// Fit view
|
|
982
|
-
//
|
|
1052
|
+
// Fit view only on intentional changes: initial load, namespace/view switch, explicit retry.
|
|
1053
|
+
// Background topology updates (new pods, status changes) must NOT trigger fitView —
|
|
1054
|
+
// doing so would cause the viewport to zoom/pan every few seconds in active clusters.
|
|
983
1055
|
useEffect(() => {
|
|
984
|
-
const structureChanged = structureKey !== prevStructureKeyRef.current
|
|
985
1056
|
const nodesJustPopulated = prevNodesLengthRef.current === 0 && nodes.length > 0
|
|
1057
|
+
const viewModeChanged = viewMode !== prevViewModeRef.current
|
|
1058
|
+
const retryRequested = layoutRetryCount !== prevRetryCountRef.current
|
|
986
1059
|
|
|
987
|
-
// Update refs
|
|
988
1060
|
prevNodesLengthRef.current = nodes.length
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
}
|
|
1061
|
+
prevViewModeRef.current = viewMode
|
|
1062
|
+
prevRetryCountRef.current = layoutRetryCount
|
|
992
1063
|
|
|
993
|
-
|
|
994
|
-
// 1. Nodes just became available (were 0, now > 0) - handles initial mount/view switch
|
|
995
|
-
// 2. Structure changed AND nodes already exist - handles topology changes
|
|
996
|
-
if (nodesJustPopulated || (structureChanged && nodes.length > 0)) {
|
|
997
|
-
// Small delay to ensure DOM is updated
|
|
1064
|
+
if (nodesJustPopulated || viewModeChanged || retryRequested) {
|
|
998
1065
|
const timeoutId = setTimeout(() => {
|
|
999
1066
|
fitView({
|
|
1000
1067
|
padding: 0.15,
|
|
1001
|
-
// No animation when nodes first appear, animate on subsequent structure changes
|
|
1002
1068
|
duration: nodesJustPopulated ? 0 : VIEWPORT_ANIMATION_DURATION,
|
|
1003
1069
|
})
|
|
1004
1070
|
}, 10)
|
|
1005
1071
|
|
|
1006
1072
|
return () => clearTimeout(timeoutId)
|
|
1007
1073
|
}
|
|
1008
|
-
}, [
|
|
1074
|
+
}, [viewMode, layoutRetryCount, nodes.length, fitView])
|
|
1009
1075
|
|
|
1010
1076
|
return null
|
|
1011
1077
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { clsx } from 'clsx'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// BADGE COMPONENT — single source of truth for all badge rendering
|
|
6
|
+
// All class names are STATIC STRINGS (required for Tailwind content scanning)
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
export type BadgeSeverity = 'success' | 'warning' | 'error' | 'info' | 'neutral'
|
|
10
|
+
export type BadgeSize = 'sm' | 'default'
|
|
11
|
+
|
|
12
|
+
interface BadgeProps {
|
|
13
|
+
/** Severity-based coloring (status badges) */
|
|
14
|
+
severity?: BadgeSeverity
|
|
15
|
+
/** K8s resource kind coloring (kind badges) */
|
|
16
|
+
kind?: string
|
|
17
|
+
/** Explicit color class override (bypasses severity/kind lookup) */
|
|
18
|
+
colorClass?: string
|
|
19
|
+
/** Size variant */
|
|
20
|
+
size?: BadgeSize
|
|
21
|
+
/** Additional classes */
|
|
22
|
+
className?: string
|
|
23
|
+
/** Click handler (renders as button) */
|
|
24
|
+
onClick?: () => void
|
|
25
|
+
/** Title/tooltip */
|
|
26
|
+
title?: string
|
|
27
|
+
children: ReactNode
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// SEVERITY COLORS — static strings for Tailwind scanning
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const SEVERITY: Record<BadgeSeverity, string> = {
|
|
34
|
+
success: 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-700/40',
|
|
35
|
+
warning: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
|
|
36
|
+
error: 'bg-red-100 text-red-700 border-red-300 dark:bg-red-950/50 dark:text-red-400 dark:border-red-700/40',
|
|
37
|
+
info: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
|
|
38
|
+
neutral: 'bg-theme-hover/50 text-theme-text-secondary border-theme-border',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// KIND COLORS — static strings, every K8s resource type
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const KIND: Record<string, string> = {
|
|
45
|
+
// Workloads
|
|
46
|
+
Deployment: 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-700/40',
|
|
47
|
+
StatefulSet: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
48
|
+
DaemonSet: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
|
|
49
|
+
ReplicaSet: 'bg-green-100 text-green-800 border-green-300 dark:bg-green-950/50 dark:text-green-400 dark:border-green-700/40',
|
|
50
|
+
Pod: 'bg-lime-100 text-lime-800 border-lime-300 dark:bg-lime-950/50 dark:text-lime-300 dark:border-lime-700/40',
|
|
51
|
+
PodGroup: 'bg-lime-100 text-lime-800 border-lime-300 dark:bg-lime-950/50 dark:text-lime-300 dark:border-lime-700/40',
|
|
52
|
+
Rollout: 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-700/40',
|
|
53
|
+
|
|
54
|
+
// Networking
|
|
55
|
+
Service: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
|
|
56
|
+
Internet: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
|
|
57
|
+
Ingress: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
|
|
58
|
+
Gateway: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
|
|
59
|
+
HTTPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
60
|
+
GRPCRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
61
|
+
TCPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
62
|
+
TLSRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
63
|
+
|
|
64
|
+
// Config
|
|
65
|
+
ConfigMap: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
|
|
66
|
+
Secret: 'bg-red-100 text-red-700 border-red-300 dark:bg-red-950/50 dark:text-red-400 dark:border-red-700/40',
|
|
67
|
+
|
|
68
|
+
// Jobs
|
|
69
|
+
Job: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
70
|
+
CronJob: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
71
|
+
|
|
72
|
+
// Scaling & Storage
|
|
73
|
+
HorizontalPodAutoscaler: 'bg-pink-100 text-pink-800 border-pink-300 dark:bg-pink-950/50 dark:text-pink-300 dark:border-pink-700/40',
|
|
74
|
+
PersistentVolumeClaim: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
75
|
+
PodDisruptionBudget: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-300 dark:border-orange-700/40',
|
|
76
|
+
|
|
77
|
+
// Cluster-scoped
|
|
78
|
+
Node: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
|
|
79
|
+
Namespace: 'bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-950/50 dark:text-gray-400 dark:border-gray-700/40',
|
|
80
|
+
|
|
81
|
+
// GitOps
|
|
82
|
+
Application: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-300 dark:border-orange-700/40',
|
|
83
|
+
Kustomization: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
|
|
84
|
+
GitRepository: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
|
|
85
|
+
HelmRelease: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
|
|
86
|
+
|
|
87
|
+
// Knative
|
|
88
|
+
KnativeService: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
89
|
+
KnativeConfiguration: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
90
|
+
KnativeRevision: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
91
|
+
KnativeRoute: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
92
|
+
Broker: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
93
|
+
Trigger: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
94
|
+
PingSource: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
95
|
+
ApiServerSource: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
96
|
+
ContainerSource: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
97
|
+
SinkBinding: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
98
|
+
Channel: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
|
|
99
|
+
|
|
100
|
+
// Traefik
|
|
101
|
+
IngressRoute: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
102
|
+
IngressRouteTCP: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
103
|
+
IngressRouteUDP: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
104
|
+
Middleware: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
105
|
+
MiddlewareTCP: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
106
|
+
TraefikService: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
107
|
+
ServersTransport: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
108
|
+
ServersTransportTCP: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
109
|
+
TLSOption: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
110
|
+
TLSStore: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
111
|
+
|
|
112
|
+
// Contour
|
|
113
|
+
HTTPProxy: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
|
|
114
|
+
|
|
115
|
+
// Events
|
|
116
|
+
Event: 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-950/50 dark:text-slate-400 dark:border-slate-700/40',
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DEFAULT_KIND_COLOR = 'bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200 dark:bg-fuchsia-950/40 dark:text-fuchsia-400 dark:border-fuchsia-800/40'
|
|
120
|
+
|
|
121
|
+
// Structure classes
|
|
122
|
+
const SIZE_CLASSES: Record<BadgeSize, string> = {
|
|
123
|
+
default: 'badge',
|
|
124
|
+
sm: 'badge-sm',
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolve kind color with fuzzy matching for plural/lowercase forms */
|
|
128
|
+
export function getKindColorClass(kind: string): string {
|
|
129
|
+
// Direct match
|
|
130
|
+
if (KIND[kind]) return KIND[kind]
|
|
131
|
+
|
|
132
|
+
// Fuzzy: try capitalizing
|
|
133
|
+
const capitalized = kind.charAt(0).toUpperCase() + kind.slice(1)
|
|
134
|
+
if (KIND[capitalized]) return KIND[capitalized]
|
|
135
|
+
|
|
136
|
+
// Fuzzy: strip trailing 's' for plural
|
|
137
|
+
const singular = kind.endsWith('s') && !kind.endsWith('ss') ? kind.slice(0, -1) : kind
|
|
138
|
+
const singularCap = singular.charAt(0).toUpperCase() + singular.slice(1)
|
|
139
|
+
if (KIND[singularCap]) return KIND[singularCap]
|
|
140
|
+
|
|
141
|
+
// Fuzzy: known plurals
|
|
142
|
+
const pluralMap: Record<string, string> = {
|
|
143
|
+
pods: 'Pod', deployments: 'Deployment', services: 'Service', ingresses: 'Ingress',
|
|
144
|
+
gateways: 'Gateway', configmaps: 'ConfigMap', secrets: 'Secret',
|
|
145
|
+
daemonsets: 'DaemonSet', statefulsets: 'StatefulSet', replicasets: 'ReplicaSet',
|
|
146
|
+
jobs: 'Job', cronjobs: 'CronJob', nodes: 'Node', namespaces: 'Namespace',
|
|
147
|
+
horizontalpodautoscalers: 'HorizontalPodAutoscaler',
|
|
148
|
+
persistentvolumeclaims: 'PersistentVolumeClaim',
|
|
149
|
+
poddisruptionbudgets: 'PodDisruptionBudget',
|
|
150
|
+
rollouts: 'Rollout', httproutes: 'HTTPRoute', grpcroutes: 'GRPCRoute',
|
|
151
|
+
events: 'Event', helmreleases: 'HelmRelease',
|
|
152
|
+
}
|
|
153
|
+
const mapped = pluralMap[kind.toLowerCase()]
|
|
154
|
+
if (mapped && KIND[mapped]) return KIND[mapped]
|
|
155
|
+
|
|
156
|
+
return DEFAULT_KIND_COLOR
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function getSeverityColorClass(severity: BadgeSeverity): string {
|
|
160
|
+
return SEVERITY[severity]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Badge component — the ONE source of truth for badge rendering.
|
|
165
|
+
* Use severity for status badges, kind for resource type badges, or colorClass for custom.
|
|
166
|
+
*/
|
|
167
|
+
export function Badge({ severity, kind, colorClass, size = 'default', className, onClick, title, children }: BadgeProps) {
|
|
168
|
+
const color = colorClass ?? (severity ? SEVERITY[severity] : kind ? getKindColorClass(kind) : '')
|
|
169
|
+
const cls = clsx(SIZE_CLASSES[size], color, className)
|
|
170
|
+
|
|
171
|
+
if (onClick) {
|
|
172
|
+
return <button onClick={onClick} className={cls} title={title}>{children}</button>
|
|
173
|
+
}
|
|
174
|
+
return <span className={cls} title={title}>{children}</span>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Re-export the raw color maps for backwards compat (used by badge-colors.ts consumers)
|
|
178
|
+
export { SEVERITY as BADGE_SEVERITY_COLORS, KIND as BADGE_KIND_COLORS }
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
|
|
1
3
|
interface Segment {
|
|
2
4
|
value: number
|
|
3
5
|
color: string
|
|
@@ -11,13 +13,29 @@ interface HealthRingProps {
|
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function HealthRing({ segments, size = 48, strokeWidth = 5, label }: HealthRingProps) {
|
|
16
|
+
const filterId = useId()
|
|
14
17
|
const total = segments.reduce((sum, s) => sum + s.value, 0)
|
|
18
|
+
|
|
19
|
+
const radius = 40
|
|
20
|
+
const circumference = 2 * Math.PI * radius
|
|
21
|
+
// 270° arc = 75% of circumference
|
|
22
|
+
const arcLength = 0.75 * circumference
|
|
23
|
+
const gapLength = circumference - arcLength
|
|
24
|
+
|
|
15
25
|
if (total === 0) {
|
|
16
26
|
return (
|
|
17
27
|
<svg width={size} height={size} viewBox="0 0 100 100" className="shrink-0">
|
|
18
|
-
|
|
28
|
+
{/* Background track (270° arc) */}
|
|
29
|
+
<circle
|
|
30
|
+
cx="50" cy="50" r={radius}
|
|
31
|
+
fill="none" stroke="currentColor" strokeWidth={strokeWidth}
|
|
32
|
+
strokeDasharray={`${arcLength} ${gapLength}`}
|
|
33
|
+
strokeLinecap="round"
|
|
34
|
+
transform="rotate(135 50 50)"
|
|
35
|
+
className="text-theme-border"
|
|
36
|
+
/>
|
|
19
37
|
{label && (
|
|
20
|
-
<text x="50" y="
|
|
38
|
+
<text x="50" y="48" textAnchor="middle" dominantBaseline="central" className="fill-theme-text-tertiary text-[22px] font-semibold font-mono">
|
|
21
39
|
0
|
|
22
40
|
</text>
|
|
23
41
|
)}
|
|
@@ -25,45 +43,62 @@ export function HealthRing({ segments, size = 48, strokeWidth = 5, label }: Heal
|
|
|
25
43
|
)
|
|
26
44
|
}
|
|
27
45
|
|
|
28
|
-
|
|
29
|
-
const circumference = 2 * Math.PI * radius
|
|
30
|
-
let accumulatedOffset = 0
|
|
46
|
+
let accumulatedFraction = 0
|
|
31
47
|
|
|
32
|
-
|
|
33
|
-
const arcs =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
48
|
+
const visibleSegments = segments.filter(s => s.value > 0)
|
|
49
|
+
const arcs = visibleSegments.map((seg, i) => {
|
|
50
|
+
const fraction = seg.value / total
|
|
51
|
+
// Scale to 270° arc (75% of circumference)
|
|
52
|
+
const dashLength = fraction * arcLength
|
|
53
|
+
// Offset within the 270° arc range
|
|
54
|
+
const offset = -accumulatedFraction * arcLength
|
|
55
|
+
const isFirst = i === 0
|
|
56
|
+
const isLast = i === visibleSegments.length - 1
|
|
57
|
+
accumulatedFraction += fraction
|
|
58
|
+
return { color: seg.color, dashLength, offset, isFirst, isLast }
|
|
59
|
+
})
|
|
43
60
|
|
|
44
61
|
return (
|
|
45
62
|
<svg width={size} height={size} viewBox="0 0 100 100" className="shrink-0">
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
<defs>
|
|
64
|
+
<filter id={`arcglow-${filterId}`}>
|
|
65
|
+
<feGaussianBlur stdDeviation="2.5" result="blur"/>
|
|
66
|
+
<feMerge>
|
|
67
|
+
<feMergeNode in="blur"/>
|
|
68
|
+
<feMergeNode in="SourceGraphic"/>
|
|
69
|
+
</feMerge>
|
|
70
|
+
</filter>
|
|
71
|
+
</defs>
|
|
72
|
+
{/* Background track (270° arc) */}
|
|
73
|
+
<circle
|
|
74
|
+
cx="50" cy="50" r={radius}
|
|
75
|
+
fill="none" stroke="currentColor" strokeWidth={strokeWidth}
|
|
76
|
+
strokeDasharray={`${arcLength} ${gapLength}`}
|
|
77
|
+
strokeLinecap="round"
|
|
78
|
+
transform="rotate(135 50 50)"
|
|
79
|
+
className="text-theme-border opacity-30"
|
|
80
|
+
/>
|
|
48
81
|
{/* Segments */}
|
|
49
|
-
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
82
|
+
<g filter={`url(#arcglow-${filterId})`}>
|
|
83
|
+
{arcs.map((arc, i) => (
|
|
84
|
+
<circle
|
|
85
|
+
key={i}
|
|
86
|
+
cx="50"
|
|
87
|
+
cy="50"
|
|
88
|
+
r={radius}
|
|
89
|
+
fill="none"
|
|
90
|
+
stroke={arc.color}
|
|
91
|
+
strokeWidth={strokeWidth}
|
|
92
|
+
strokeDasharray={`${arc.dashLength} ${circumference - arc.dashLength}`}
|
|
93
|
+
strokeDashoffset={arc.offset}
|
|
94
|
+
strokeLinecap={arc.isFirst || arc.isLast ? 'round' : 'butt'}
|
|
95
|
+
transform="rotate(135 50 50)"
|
|
96
|
+
/>
|
|
97
|
+
))}
|
|
98
|
+
</g>
|
|
99
|
+
{/* Center label — nudged up slightly for visual centering in 270° arc */}
|
|
65
100
|
{label && (
|
|
66
|
-
<text x="50" y="
|
|
101
|
+
<text x="50" y="48" textAnchor="middle" dominantBaseline="central" className="fill-theme-text-primary text-[22px] font-semibold font-mono">
|
|
67
102
|
{label}
|
|
68
103
|
</text>
|
|
69
104
|
)}
|