feat: real service runtime — private networks, replicas, lifecycle, rollback

Services now reconcile into actual Docker containers instead of
bookkeeping rows:

- Per-project bridge network with service-name DNS aliases and
  ${{service.KEY}} env expansion (HOST/PORT builtins)
- Replicas, published ports, Traefik domain routing, health checks,
  restart policies persisted on the service spec; spec-hash drift
  recreates containers on redeploy
- Lifecycle API: start/stop/restart/redeploy, live runtime state,
  real image rollback (replaces the fake sleep)
- Manual scale and PUT replicas reconcile live containers; service
  and project delete remove their containers and the project network
- Canvas: real URLs, replica badges, env filter, right-click
  deploy/restart/stop/delete menu, empty state
- Service detail: real lifecycle buttons, live replica/URL strip,
  Networking editor, ${{ }} variable helper
- Fix prebuilt-image pulls racing the tag (drain the pull stream)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-21 19:31:19 +02:00
1 parent c16b32f56a
commit ff356230fe
20 files changed
+2270 -243

No files matched your search

@@ -11,7 +11,15 @@ import {
type NodeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { ServiceEntity } from '@/lib/api-client';
import {
createDeployment,
deleteService,
restartService,
stopService,
} from '@/lib/api-client';
import { useToast } from '@/shared/hooks/use-toast';
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
import {
DEFAULT_VIEWPORT,
@@ -21,7 +29,20 @@ import {
} from '../model';
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
import {
Plus,
Layers,
Maximize2,
RotateCcw,
Box,
Link2,
Play,
Square,
Rocket,
Trash2,
ArrowUpRight,
Globe,
} from 'lucide-react';
type CanvasProps = {
projectId: string;
@@ -46,6 +67,12 @@ const nodeTypes: NodeTypes = {
groupNode: GroupNode,
};
type ContextMenuState = {
serviceId: string;
x: number;
y: number;
} | null;
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] {
const groups = metadata.groups.map(
(group): GroupCanvasNode => ({
@@ -157,9 +184,24 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
const [viewportTick, setViewportTick] = useState(0);
const [envFilter, setEnvFilter] = useState<string>('all');
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
const queryClient = useQueryClient();
const toast = useToast();
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>();
const environments = useMemo(() => {
const envs = new Set(services.map((service) => service.environment ?? 'production'));
return Array.from(envs).sort();
}, [services]);
const visibleServices = useMemo(
() => (envFilter === 'all' ? services : services.filter((service) => (service.environment ?? 'production') === envFilter)),
[services, envFilter],
);
const visibleIds = useMemo(() => new Set(visibleServices.map((service) => service.id)), [visibleServices]);
const serviceFingerprint = useMemo(
() => services.map((service) => service.id).sort().join('|'),
[services],
@@ -168,7 +210,53 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
() => inferAutoConnections(services, variablesByService),
[services, variablesByService],
);
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
const edges = useMemo(
() => toFlowEdges(inferredLinks).filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)),
[inferredLinks, visibleIds],
);
const invalidateServices = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
void queryClient.invalidateQueries({ queryKey: ['service-runtime'] });
}, [projectId, queryClient]);
const actionError = (error: unknown) =>
toast.showToast(error instanceof Error ? error.message : 'Action failed', 'error');
const deployMutation = useMutation({
mutationFn: (serviceId: string) => createDeployment(serviceId, { trigger: 'manual' }),
onSuccess: () => {
toast.showToast('Deployment started', 'success');
invalidateServices();
},
onError: actionError,
});
const restartMutation = useMutation({
mutationFn: (serviceId: string) => restartService(serviceId),
onSuccess: () => {
toast.showToast('Service restarted', 'success');
invalidateServices();
},
onError: actionError,
});
const stopMutation = useMutation({
mutationFn: (serviceId: string) => stopService(serviceId),
onSuccess: () => {
toast.showToast('Service stopped', 'success');
invalidateServices();
},
onError: actionError,
});
const deleteMutation = useMutation({
mutationFn: (serviceId: string) => deleteService(serviceId),
onSuccess: () => {
toast.showToast('Service deleted', 'success');
invalidateServices();
},
onError: actionError,
});
const actionPending =
deployMutation.isPending || restartMutation.isPending || stopMutation.isPending || deleteMutation.isPending;
useEffect(() => {
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
@@ -347,6 +435,35 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
]);
}, [screenToFlowPosition, setNodes]);
// Env filter hides nodes via React Flow's `hidden` flag — positions and
// persisted layout stay intact for services outside the current filter.
const renderedNodes = useMemo(
() =>
nodes.map((node) =>
node.type === 'serviceNode' && !visibleIds.has(node.id) ? { ...node, hidden: true } : node,
),
[nodes, visibleIds],
);
const onNodeContextMenu = useCallback(
(event: MouseEvent, node: CanvasNode) => {
if (node.type !== 'serviceNode') {
return;
}
event.preventDefault();
const bounds = wrapperRef.current?.getBoundingClientRect();
setSelectedServiceId(node.id);
setContextMenu({
serviceId: node.id,
x: event.clientX - (bounds?.left ?? 0),
y: event.clientY - (bounds?.top ?? 0),
});
},
[],
);
const contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null;
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
const selectedServiceLinkCount = selectedService
? inferredLinks.filter(
@@ -377,7 +494,29 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
<Layers size={14} />
Group
</button>
{environments.length > 1 && (
<>
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-1">
{['all', ...environments].map((env) => (
<button
key={env}
type="button"
onClick={() => setEnvFilter(env)}
className={`px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-all ${
envFilter === env
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
>
{env}
</button>
))}
</div>
</>
)}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
@@ -411,14 +550,19 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
</div>
{/* Canvas */}
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)]">
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)] relative">
<ReactFlow
nodes={nodes}
nodes={renderedNodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onPaneClick={() => setSelectedServiceId(null)}
onPaneClick={() => {
setSelectedServiceId(null);
setContextMenu(null);
}}
onMoveStart={() => setContextMenu(null)}
onNodeClick={(_event, node) => {
setContextMenu(null);
if (node.type === 'serviceNode') {
setSelectedServiceId(node.id);
}
@@ -428,6 +572,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
onOpenService(node.id);
}
}}
onNodeContextMenu={onNodeContextMenu}
onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)}
panOnDrag
@@ -443,6 +588,118 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
</svg>
<Background color="rgba(255,255,255,0.05)" gap={28} />
</ReactFlow>
{/* Empty state */}
{services.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center pointer-events-auto">
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Rocket size={24} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
Add a service from a git repo or Docker image — it runs on a private network with the rest of this
project.
</p>
<button
type="button"
onClick={onAddService}
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={15} />
Add Service
</button>
</div>
</div>
)}
{/* Node context menu */}
{contextMenu && contextService && (
<div
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] shadow-2xl py-1"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextService.name}</p>
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</p>
</div>
<button
type="button"
onClick={() => {
setContextMenu(null);
onOpenService(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<ArrowUpRight size={13} />
Open service
</button>
{contextService.domain && (
<a
href={`https://${contextService.domain}`}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Globe size={13} />
Visit {contextService.domain}
</a>
)}
<button
type="button"
disabled={actionPending}
onClick={() => {
setContextMenu(null);
deployMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Rocket size={13} />
Deploy
</button>
<button
type="button"
disabled={actionPending || contextService.status !== 'running'}
onClick={() => {
setContextMenu(null);
restartMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Play size={13} />
Restart
</button>
<button
type="button"
disabled={actionPending || contextService.status !== 'running'}
onClick={() => {
setContextMenu(null);
stopMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Square size={13} />
Stop
</button>
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
<button
type="button"
disabled={actionPending}
onClick={() => {
setContextMenu(null);
if (window.confirm(`Delete service "${contextService.name}"? Its containers will be removed.`)) {
deleteMutation.mutate(contextService.id);
}
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-50"
>
<Trash2 size={13} />
Delete
</button>
</div>
</div>
)}
</div>
{/* Footer */}
@@ -465,7 +722,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
</div>
) : (
<p className="text-xs text-[var(--text-tertiary)] text-center">
Click a service to select • Double-click to open • Drag to reposition • Connections auto-inferred from variables
Click to select • Double-click to open • Right-click for actions • Connections auto-inferred from variables
</p>
)}
</div>