import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import type { ServiceEntity } from '@/lib/api-client'; import { serviceStatusClass } from '@/lib/api-client'; import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react'; import { useState } from 'react'; import { serviceIcon, serviceAccent } from './service-visuals'; import { ServiceIcon } from './ServiceIcon'; export type ServiceNodeData = { service: ServiceEntity; selected: boolean; onOpen: (serviceId: string) => void; }; export type GroupNodeData = { title: string; onRename?: (groupId: string, title: string) => void; renameNonce?: number; }; export type ServiceNodeType = Node; export type GroupNodeType = Node; function statusLabel(status: string): string { switch (status) { case 'running': return 'Online'; case 'degraded': return 'Degraded'; case 'building': return 'Deploying'; case 'failed': return 'Failed'; default: return 'Stopped'; } } function statusColor(status: string): string { switch (status) { case 'running': return 'var(--success)'; case 'degraded': case 'building': return 'var(--warning)'; case 'failed': return 'var(--error)'; default: return 'var(--text-tertiary)'; } } const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']); export function ServiceNode({ data }: NodeProps) { const { service, selected } = data; const brand = serviceIcon(service); const typeColor = brand?.color ?? serviceAccent(service); const replicas = service.replicas ?? 0; const stateColor = statusColor(service.status); const isTransient = TRANSIENT_STATUSES.has(service.status); const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl; const internalAddr = service.port ? `${service.name}:${service.port}` : service.name; return (
{isTransient ? ( ) : ( )}

{service.name}

{service.type} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}

{replicas > 1 && ( {replicas} )}
{publicUrl ? ( e.stopPropagation()} className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline" > {publicUrl.replace(/^https?:\/\//, '')} ) : ( {internalAddr} )}
{statusLabel(service.status)}
{service.image && ( {service.image} )}
); } export function GroupNode({ id, data }: NodeProps) { const [isHovered, setIsHovered] = useState(false); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(data.title); // Context-menu rename requests bump the nonce; enter edit mode when it // changes. Render-phase state adjustment — the React-sanctioned pattern. const [seenRenameNonce, setSeenRenameNonce] = useState(data.renameNonce ?? 0); const incomingNonce = data.renameNonce ?? 0; if (incomingNonce > seenRenameNonce) { setSeenRenameNonce(incomingNonce); setDraft(data.title); setEditing(true); } const commit = () => { const next = draft.trim() || data.title; setEditing(false); if (next !== data.title) { data.onRename?.(id, next); } }; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} >
{editing ? ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') { setDraft(data.title); setEditing(false); } }} className="nodrag h-7 px-2 rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--surface-card)] text-sm font-semibold text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]" /> ) : (

{ e.stopPropagation(); setDraft(data.title); setEditing(true); }} title="Double-click to rename" > {data.title}

)}

Drag services here to group them

); }