Files
Containr/app/frontend/src/features/workspace/canvas/nodes.tsx
T
Tomas DvorakandDevin 7fc6ed1215 feat(canvas): full service cockpit on left-click + real grouping
The inspector is now a proper service panel: address with copy, group
assignment (Ungrouped / any group / "+ New group" wrapping the node),
masked env vars with a deep link into the service's Variables tab, a
config grid deep-linking to Config, inferred connections, and the
lifecycle actions. ServiceDetailPage honours ?section= for the deep
links.

Grouping is first-class: assign via the inspector or drag nodes onto a
frame; members slot beneath existing ones and the frame grows only as
needed instead of clamping or ballooning. Fixed a drag bug where moving
a member inside its own group ejected it.

Context menus everywhere: right-click a node (existing actions), a
group frame (Rename via inline edit, Ungroup keeps children in place),
or empty pane (Add service, Add group here, Tidy layout, Fit view).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:50:38 +02:00

253 lines
9.6 KiB
TypeScript

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<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
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<ServiceNodeType>) {
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 (
<div
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
}`}
>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}08` }}
/>
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle id="t-r" type="target" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle id="s-l" type="source" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle id="s-r" type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
<div
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${service.status === 'running' ? 'opacity-100' : 'opacity-40'}`}
style={{ background: stateColor }}
/>
<div className="relative px-3.5 pt-3.5 pb-3">
<div className="flex items-center gap-3 mb-2.5">
<div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
style={{
background: selected && !brand ? typeColor : `${typeColor}1c`,
color: selected && !brand ? 'var(--bg-void)' : typeColor,
}}
>
{isTransient ? (
<Loader2 size={16} className="animate-spin" />
) : (
<ServiceIcon service={service} size={17} />
)}
</div>
<div className="min-w-0 flex-1">
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
{service.name}
</h4>
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p>
</div>
{replicas > 1 && (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]">
<Layers size={9} />
{replicas}
</span>
)}
</div>
<div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? (
<a
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
{internalAddr}
</span>
)}
<button
type="button"
title="Copy internal address"
onClick={(e) => {
e.stopPropagation();
void navigator.clipboard?.writeText(internalAddr);
}}
className="ml-auto opacity-0 group-hover/addr:opacity-100 transition-opacity text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
<Copy size={10} />
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
{statusLabel(service.status)}
</span>
</div>
{service.image && (
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
{service.image}
</span>
)}
</div>
</div>
</div>
);
}
export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
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 (
<div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
style={{
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative p-4">
<div className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
}}
>
<Box size={14} />
</div>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => 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)]"
/>
) : (
<h3
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
onDoubleClick={(e) => {
e.stopPropagation();
setDraft(data.title);
setEditing(true);
}}
title="Double-click to rename"
>
{data.title}
</h3>
)}
</div>
<p
className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
style={{ opacity: isHovered ? 1 : 0 }}
>
Drag services here to group them
</p>
</div>
</div>
);
}