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

@@ -29,6 +29,10 @@ import {
execInService,
rollbackDeployment,
updateServiceVariables,
updateService,
restartService,
stopService,
getServiceRuntime,
type CronJobEntity,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
@@ -365,6 +369,39 @@ export function ServiceDetailPage() {
},
});
const invalidateService = () => {
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
};
const restartMutation = useMutation({
mutationFn: () => restartService(serviceId),
onSuccess: invalidateService,
});
const stopMutation = useMutation({
mutationFn: () => stopService(serviceId),
onSuccess: invalidateService,
});
const updateServiceMutation = useMutation({
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
onSuccess: () => {
setNetworkForm(null);
invalidateService();
},
});
const runtimeQuery = useQuery({
queryKey: ['service-runtime', serviceId],
queryFn: () => getServiceRuntime(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
refetchInterval: 10_000,
});
const [networkForm, setNetworkForm] = useState<{
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
} | null>(null);
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
@@ -558,15 +595,15 @@ export function ServiceDetailPage() {
</span>
</div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
{service.status === 'running' && (
<a
href={`https://${service.name}.containr.dev`}
{(service.publicUrl || service.domain) && (
<a
href={service.publicUrl ?? `https://${service.domain}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
https://{service.name}.containr.dev
{service.publicUrl ?? `https://${service.domain}`}
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
@@ -590,15 +627,26 @@ export function ServiceDetailPage() {
{!isDemoMode && (
<>
<button
onClick={() => deployMutation.mutate('restart')}
disabled={deployMutation.isPending || service.status !== 'running'}
onClick={() => stopMutation.mutate()}
disabled={stopMutation.isPending || service.status !== 'running'}
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
STOP
{stopMutation.isPending ? 'STOPPING…' : 'STOP'}
</button>
<button
onClick={() => restartMutation.mutate()}
disabled={restartMutation.isPending || service.status !== 'running'}
className={`btn-restart ${service.status !== 'running' ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
</svg>
{restartMutation.isPending ? 'RESTARTING…' : 'RESTART'}
</button>
<button
onClick={() => deployMutation.mutate('manual')}
@@ -606,10 +654,10 @@ export function ServiceDetailPage() {
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
</svg>
RESTART
{deployMutation.isPending ? 'DEPLOYING…' : 'DEPLOY'}
</button>
</>
)}
@@ -1078,7 +1126,11 @@ export function ServiceDetailPage() {
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
<p className="text-sm text-[var(--text-secondary)]">Runtime values injected into the service container</p>
<p className="text-sm text-[var(--text-secondary)]">
Runtime values injected at deploy. Reference another service's variable with{' '}
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -1775,6 +1827,159 @@ export function ServiceDetailPage() {
</div>
</div>
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Networking</h3>
{networkForm === null && (
<button
type="button"
onClick={() =>
setNetworkForm({
port: service.port ? String(service.port) : '',
domain: service.domain ?? '',
healthcheckPath: service.healthcheckPath ?? '',
restartPolicy: service.restartPolicy ?? 'unless-stopped',
replicas: String(service.replicas ?? 1),
})
}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Edit
</button>
)}
</div>
{runtimeQuery.data && (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 p-3">
<div className="flex items-center gap-4 text-xs">
<span className="text-[var(--text-tertiary)]">
Live: <span className="text-[var(--text-primary)] font-medium">{runtimeQuery.data.containers.filter((c) => c.state === 'running').length}/{runtimeQuery.data.desired}</span> replicas
</span>
<span className="text-[var(--text-tertiary)]">
Internal: <span className="mono text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
</span>
{runtimeQuery.data.urls.map((url) => (
<a key={url} href={url} target="_blank" rel="noopener noreferrer" className="mono text-[var(--accent-primary)] hover:underline">
{url}
</a>
))}
</div>
</div>
)}
{networkForm === null ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Port</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.port || '—'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Domain</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)] truncate">{service.domain || '—'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.replicas ?? 1}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Restart</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.restartPolicy || 'unless-stopped'}</p>
</div>
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Container port</label>
<input
type="number"
min={0}
max={65535}
value={networkForm.port}
onChange={(e) => setNetworkForm({ ...networkForm, port: e.target.value })}
placeholder="e.g. 3000"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
/>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">Published on the host; routed via Traefik when a domain is set</p>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Domain</label>
<input
value={networkForm.domain}
onChange={(e) => setNetworkForm({ ...networkForm, domain: e.target.value })}
placeholder="app.example.com"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Health check path</label>
<input
value={networkForm.healthcheckPath}
onChange={(e) => setNetworkForm({ ...networkForm, healthcheckPath: e.target.value })}
placeholder="/health"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Replicas</label>
<input
type="number"
min={1}
max={20}
value={networkForm.replicas}
onChange={(e) => setNetworkForm({ ...networkForm, replicas: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Restart policy</label>
<select
value={networkForm.restartPolicy}
onChange={(e) => setNetworkForm({ ...networkForm, restartPolicy: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
<option value="unless-stopped">unless-stopped</option>
<option value="always">always</option>
<option value="on-failure">on-failure</option>
<option value="no">no</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() =>
updateServiceMutation.mutate({
port: Number(networkForm.port) || 0,
domain: networkForm.domain,
healthcheck_path: networkForm.healthcheckPath,
restart_policy: networkForm.restartPolicy,
replicas: Math.max(1, Math.min(20, Number(networkForm.replicas) || 1)),
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setNetworkForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Takes effect on next deploy or redeploy</p>
</div>
{updateServiceMutation.isError && (
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
@@ -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>
@@ -1,7 +1,7 @@
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, ArrowUpRight, Globe, Database, Terminal, MoreHorizontal, ExternalLink } from 'lucide-react';
import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react';
import { useState } from 'react';
export type ServiceNodeData = {
@@ -33,24 +33,38 @@ function serviceTypeIcon(type: string): typeof Box {
function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#7ab8ff'; // Blue
return '#7ab8ff';
case 'database':
return '#b4e34a'; // Purple
return '#b4e34a';
case 'worker':
return 'var(--accent-primary)'; // Pink
return 'var(--accent-primary)';
default:
return '#9295a4'; // Gray
return '#9295a4';
}
}
function statusLabel(status: string): string {
switch (status) {
case 'running':
return 'Online';
case 'building':
return 'Deploying';
case 'failed':
return 'Failed';
default:
return 'Stopped';
}
}
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected, onOpen } = data;
const iconType = serviceTypeIcon(service.type);
const { service, selected } = data;
const Icon = serviceTypeIcon(service.type);
const typeColor = serviceTypeColor(service.type);
const isRunning = service.status === 'running';
const replicas = service.replicas ?? 0;
// Generate a mock domain for display (in real app, this would come from service data)
const domain = `${service.name}.containr.local`;
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
return (
<div
@@ -61,80 +75,88 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
}`}
style={{ minWidth: 220, maxWidth: 280 }}
>
{/* Ambient overlay */}
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}10` }}
/>
{/* Invisible connection points - required by React Flow for edge attachment */}
<Handle type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
{/* Status indicator bar at top */}
<div
<div
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${isRunning ? 'opacity-100' : 'opacity-40'}`}
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
/>
{/* Action menu on hover */}
<button className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1.5 rounded-lg hover:bg-[var(--surface-muted)]">
<MoreHorizontal size={14} />
</button>
<div className="relative p-4">
{/* Header with icon and name */}
<div className="flex items-start gap-3 mb-3">
<div
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
selected ? 'ring-2 ring-white/20' : ''
}`}
style={{
style={{
background: selected ? typeColor : `${typeColor}20`,
color: selected ? 'white' : typeColor
color: selected ? 'white' : typeColor,
}}
>
{iconType === Globe && <Globe size={18} />}
{iconType === Database && <Database size={18} />}
{iconType === Terminal && <Terminal size={18} />}
{iconType === Box && <Box size={18} />}
<Icon size={18} />
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">{service.type}</p>
<p className="text-[11px] 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>
{/* Domain display - Railway style */}
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50">
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="text-[10px] text-[var(--text-secondary)] truncate mono">{domain}</span>
{publicUrl ? (
<a
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="text-[10px] 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>
{/* Status and action row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span
className="text-[11px] font-semibold tracking-wide uppercase"
<span
className="text-[11px] font-semibold tracking-wide uppercase"
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
>
{isRunning ? 'Online' : service.status}
{statusLabel(service.status)}
</span>
</div>
<button
type="button"
onClick={() => onOpen(service.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all duration-200 ${
selected
? 'bg-[var(--accent-primary)] text-[var(--accent-on)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]'
}`}
>
Open
<ArrowUpRight size={10} />
</button>
<span className="text-[10px] text-[var(--text-tertiary)]">Right-click for actions</span>
</div>
</div>
</div>
@@ -147,46 +169,41 @@ export function GroupNode({ data }: NodeProps<GroupNodeType>) {
return (
<div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
style={{
style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30'
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Ambient glow on hover */}
<div
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
style={{
style={{
background: 'rgba(232,49,106,0.03)',
opacity: isHovered ? 1 : 0
opacity: isHovered ? 1 : 0,
}}
/>
{/* Header */}
<div className="relative p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div
<div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{
style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)'
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
}}
>
<Box size={14} />
</div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
</div>
{isHovered && (
<button className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--surface-muted)]">
<MoreHorizontal size={14} />
</button>
)}
</div>
{/* Helper text */}
<p className="text-[10px] text-[var(--text-tertiary)] mt-2 opacity-0 transition-opacity duration-200" style={{ opacity: isHovered ? 1 : 0 }}>
<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>
+242
View File
@@ -604,6 +604,216 @@ export interface paths {
patch?: never;
trace?: never;
};
"/services/{id}/runtime": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Service runtime state
* @description Live container state — replicas, URLs, aggregate status
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Runtime state */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
runtime?: {
desired?: number;
/** @enum {string} */
status?: "running" | "degraded" | "stopped";
urls?: string[];
containers?: {
id?: string;
name?: string;
state?: string;
replica?: number;
}[];
};
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/start": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Start service
* @description Start all stopped replicas
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service started */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/stop": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Stop service
* @description Stop all replicas without removing them
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service stopped */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/restart": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Restart service
* @description Restart all replicas
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service restarted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/redeploy": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Redeploy service
* @description Re-apply the current spec — pulls the image for image-sourced services, reuses the last deployed image otherwise
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Reconciled runtime state */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/deployments": {
parameters: {
query?: never;
@@ -6928,6 +7138,18 @@ export interface components {
cpu?: string;
/** @description Memory allocation */
memory?: string;
/** @description Desired replica count */
replicas?: number;
/** @description Container port to expose (published host port, or Traefik when domain set) */
port?: number;
/** @description Public hostname routed via Traefik */
domain?: string;
/** @description HTTP path probed on port for container health */
healthcheck_path?: string;
/** @description Docker restart policy (default unless-stopped) */
restart_policy?: string;
/** @description Computed public URL (published port or domain) */
public_url?: string;
/**
* Format: date-time
* @description Service creation timestamp
@@ -6971,6 +7193,16 @@ export interface components {
cpu?: string;
/** @description Memory allocation */
memory?: string;
/** @description Desired replica count */
replicas?: number;
/** @description Container port to expose */
port?: number;
/** @description Public hostname routed via Traefik */
domain?: string;
/** @description HTTP path probed for container health */
healthcheck_path?: string;
/** @description Docker restart policy */
restart_policy?: string;
};
UpdateServiceRequest: {
/** @description Service name */
@@ -6999,6 +7231,16 @@ export interface components {
cpu?: string;
/** @description Memory allocation */
memory?: string;
/** @description Desired replica count */
replicas?: number;
/** @description Container port to expose */
port?: number;
/** @description Public hostname routed via Traefik */
domain?: string;
/** @description HTTP path probed for container health */
healthcheck_path?: string;
/** @description Docker restart policy */
restart_policy?: string;
};
Deployment: {
/** @description Deployment ID */
+70
View File
@@ -48,6 +48,26 @@ export type ServiceEntity = {
buildPath?: string;
cpu?: string;
memory?: string;
replicas?: number;
port?: number;
domain?: string;
healthcheckPath?: string;
restartPolicy?: string;
publicUrl?: string;
};
export type RuntimeContainer = {
id: string;
name: string;
state: string;
replica: number;
};
export type ServiceRuntime = {
desired: number;
status: 'running' | 'degraded' | 'stopped';
urls: string[];
containers: RuntimeContainer[];
};
export type ServiceVariable = {
@@ -343,6 +363,7 @@ type RawNodeAgent = components['schemas']['NodeAgent'];
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
export type CreateServiceInput = components['schemas']['CreateServiceRequest'];
export type UpdateServiceInput = components['schemas']['UpdateServiceRequest'];
export class ApiError extends Error {
readonly status: number;
@@ -515,6 +536,12 @@ function normalizeService(service: RawService): ServiceEntity | null {
buildPath: service.build_path,
cpu: service.cpu,
memory: service.memory,
replicas: service.replicas,
port: service.port,
domain: service.domain,
healthcheckPath: service.healthcheck_path,
restartPolicy: service.restart_policy,
publicUrl: service.public_url,
};
}
@@ -998,12 +1025,55 @@ export async function createService(projectId: string, input: CreateServiceInput
return parsed;
}
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
method: 'PUT',
body: JSON.stringify(input),
});
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
const parsed = normalizeService(row);
if (!parsed) {
throw new ApiError('Update service response is invalid', 500);
}
return parsed;
}
export async function deleteService(serviceId: string): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}`, {
method: 'DELETE',
});
}
export async function getServiceRuntime(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/runtime`);
return (
payload.runtime ?? {
desired: 0,
status: 'stopped',
urls: [],
containers: [],
}
);
}
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
}
export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
method: 'POST',
});
return payload.runtime ?? { desired: 0, status: 'stopped', urls: [], containers: [] };
}
function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] {
const result: ServiceVariable[] = [];
for (const row of rows ?? []) {