Files
resolutionflow/frontend/src/components/network/nodes/DeviceNode.tsx
Michael Chihlas 920a246d77
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 11m23s
CI / frontend (pull_request) Failing after 2m42s
CI / e2e (pull_request) Has been skipped
fix(react): remove four setState-in-effect cascades flagged by react-hooks v5
The new react-hooks lint rule "Calling setState synchronously within an
effect can trigger cascading renders" flagged real anti-patterns in
four spots. Refactored each per the rule's intent (derive during render,
or use useSyncExternalStore for external subscriptions).

1. hooks/useMediaQuery.ts — replaced the useState + useEffect pair with
   useSyncExternalStore. That's the canonical React hook for
   subscribing to external stores (matchMedia in this case) without
   mirroring into local state via an effect. Snapshot/getServerSnapshot
   pair preserves the SSR-safe behaviour.

2. components/network/nodes/DeviceNode.tsx — the prop-sync useEffect
   that copied nodeData.label into labelValue was redundant.
   labelValue is the EDIT BUFFER; while not editing, the displayed
   span now reads nodeData.label directly. The buffer is initialized
   only when an edit session starts (onDoubleClick).

3. components/network/nodes/GroupNode.tsx — same pattern, same fix.

4. components/dashboard/TicketQueue.tsx — the
   setTickets([]) + setLoading(true) + fetchTickets() chain in the
   effect was the cascade. Pushed those writes inside fetchTickets
   (after the function boundary, so they batch with the eventual
   setTickets(result)). Added a request-id ref so a slow first
   response can't overwrite a fast second one.

Frontend lint: 20 errors → 0 errors. tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 02:33:13 -04:00

181 lines
7.7 KiB
TypeScript

import { memo, useState, useRef, useEffect } from 'react'
import { Position, NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react'
import { BaseNode, BaseNodeHeader, BaseNodeContent } from '../ui/base-node'
import { BaseHandle } from '../ui/base-handle'
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip'
import { getDeviceRenderConfig, CATEGORY_LABELS } from './deviceRegistry'
import { cn } from '@/lib/utils'
import type { DeviceProperties } from '@/types'
export interface DeviceNodeData {
label: string
deviceType: string
category?: string
properties: DeviceProperties
[key: string]: unknown
}
function TooltipRow({ label, value }: { label: string; value: string | null | undefined }) {
if (!value) return null
return (
<div className="flex gap-2">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
<span className="text-xs font-mono text-primary">{value}</span>
</div>
)
}
const NODE_DEFAULT = 120 // default square side in px
const NODE_MIN = 80 // minimum square side in px
const NODE_MAX = 280 // maximum square side in px
function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) {
const nodeData = data as unknown as DeviceNodeData
const { icon: Icon, color, accentClass, surfaceClass, category } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
const ip = nodeData.properties?.ip
const props = nodeData.properties || {}
// Use the shorter dimension so content never overflows a non-square node
const size = Math.min(width ?? NODE_DEFAULT, height ?? NODE_DEFAULT)
const scale = size / NODE_DEFAULT
// Icon: 28px at default, clamped to [14, 72]
const iconPx = Math.round(Math.max(14, Math.min(72, scale * 28)))
// Label font: 11px at default, clamped to [9, 20]
const labelPx = Math.max(9, Math.min(20, Math.round(scale * 11)))
// IP font: 9px at default, clamped to [8, 16]
const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9)))
const metaPx = Math.max(8, Math.min(11, Math.round(scale * 8)))
const iconPlateSize = Math.round(Math.max(34, Math.min(82, scale * 50)))
const [editing, setEditing] = useState(false)
const [labelValue, setLabelValue] = useState(nodeData.label ?? '')
const inputRef = useRef<HTMLInputElement>(null)
const { updateNodeData } = useReactFlow()
useEffect(() => {
if (editing) {
inputRef.current?.focus()
inputRef.current?.select()
}
}, [editing])
// While not editing, the displayed label is derived directly from
// nodeData.label — no effect-driven sync needed. labelValue holds the
// edit buffer only and is reset when an edit session starts.
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
return (
<>
<NodeResizer
isVisible={selected}
minWidth={NODE_MIN}
minHeight={NODE_MIN}
maxWidth={NODE_MAX}
maxHeight={NODE_MAX}
keepAspectRatio
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
/>
<NodeStatusIndicator status={status}>
<NodeTooltip>
<NodeTooltipTrigger>
<BaseNode className="group h-full w-full bg-card">
<div className={cn('pointer-events-none absolute inset-x-0 top-0 h-10 bg-gradient-to-b opacity-80', surfaceClass)} />
<BaseNodeHeader className="flex h-full flex-col items-center justify-center gap-2 px-2 py-3">
<div
className={cn(
'relative flex items-center justify-center rounded-xl border transition-colors',
accentClass,
)}
style={{ width: iconPlateSize, height: iconPlateSize }}
>
<div className="absolute inset-[4px] rounded-[10px] border border-white/[0.06] bg-sidebar/50" />
<div className="relative z-10">
<Icon size={iconPx} style={{ color }} />
</div>
</div>
{editing ? (
<input
ref={inputRef}
value={labelValue}
onChange={e => setLabelValue(e.target.value)}
onBlur={() => {
setEditing(false)
if (labelValue !== nodeData.label) {
updateNodeData(id, { ...nodeData, label: labelValue })
}
}}
onKeyDown={e => {
if (e.key === 'Enter') inputRef.current?.blur()
if (e.key === 'Escape') {
setLabelValue(nodeData.label ?? '')
setEditing(false)
}
e.stopPropagation()
}}
style={{ fontSize: labelPx }}
className="bg-transparent border-none outline-none text-center text-primary font-medium w-4/5"
/>
) : (
<span
style={{ fontSize: labelPx }}
className="max-w-[88%] cursor-default text-center font-medium leading-tight text-primary line-clamp-2"
onDoubleClick={e => {
e.stopPropagation()
setLabelValue(nodeData.label ?? '')
setEditing(true)
}}
>
{nodeData.label ?? ''}
</span>
)}
<span
style={{ fontSize: metaPx }}
className="text-[9px] uppercase tracking-[0.16em] text-muted"
>
{CATEGORY_LABELS[category] ?? nodeData.deviceType.replace(/-/g, ' ')}
</span>
</BaseNodeHeader>
{ip && (
<BaseNodeContent className="items-center pt-0 pb-2">
<span
className="rounded-full border border-default bg-page/70 px-2 py-0.5 font-mono text-muted-foreground"
style={{ fontSize: ipPx }}
>
{ip}
</span>
</BaseNodeContent>
)}
<BaseHandle type="target" position={Position.Top} />
<BaseHandle type="source" position={Position.Bottom} />
<BaseHandle type="target" position={Position.Left} id="left" />
<BaseHandle type="source" position={Position.Right} id="right" />
</BaseNode>
</NodeTooltipTrigger>
{hasTooltipContent && (
<NodeTooltipContent position={Position.Top}>
<div className="flex flex-col gap-1 min-w-[140px]">
<TooltipRow label="Host" value={props.hostname} />
<TooltipRow label="IP" value={props.ip} />
{(props.vendor || props.model) && (
<TooltipRow label="HW" value={[props.vendor, props.model].filter(Boolean).join(' ')} />
)}
<TooltipRow label="Role" value={props.role} />
{props.notes && (
<TooltipRow label="Notes" value={props.notes.length > 100 ? props.notes.slice(0, 100) + '...' : props.notes} />
)}
</div>
</NodeTooltipContent>
)}
</NodeTooltip>
</NodeStatusIndicator>
</>
)
}
export const DeviceNode = memo(DeviceNodeComponent)