mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
fix: polish service runtime UX and harden reconcile edge cases
Backend: - tolerate concurrent project-network creation (re-resolve on conflict) - always publish an ephemeral host port when a port is configured, even with a domain set — Traefik routes on top when the edge network exists - public_url now prefers the configured domain, falls back to the first published port - service logs resolve replicas by containr.service label and merge all replica streams — previously queried a nonexistent containr-<id> name Canvas/detail: - degraded status renders amber end-to-end (node card, detail badge, status dot) instead of falling through to "Stopped" - edges pick handle sides by relative node position — no more backward loops when the source sits right of the target - context menu gains Start for stopped services, Visit uses the published URL when no domain is set, menu clamps to canvas bounds - node cards show the image tag instead of a redundant hint - scaling tab falls back to live runtime replica counts when no autoscaler state exists; manual scale no longer requires it Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
04c3b99531
commit
b7e2e93261
9 files changed
+193
-69
No files matched your search
@@ -17,6 +17,7 @@ import {
|
||||
createDeployment,
|
||||
deleteService,
|
||||
restartService,
|
||||
startService,
|
||||
stopService,
|
||||
} from '@/lib/api-client';
|
||||
import { useToast } from '@/shared/hooks/use-toast';
|
||||
@@ -118,27 +119,40 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[],
|
||||
return [...groups, ...serviceNodes];
|
||||
}
|
||||
|
||||
function toFlowEdges(links: ReturnType<typeof inferAutoConnections>): CanvasEdge[] {
|
||||
return links.map((link) => ({
|
||||
id: link.edge.id,
|
||||
source: link.edge.sourceServiceId,
|
||||
target: link.edge.targetServiceId,
|
||||
animated: false,
|
||||
data: {
|
||||
reasons: link.reasons,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: 'var(--accent-secondary)',
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
style: {
|
||||
stroke: 'var(--accent-primary)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
className: 'edge-premium',
|
||||
}));
|
||||
function toFlowEdges(
|
||||
links: ReturnType<typeof inferAutoConnections>,
|
||||
positionOf: (serviceId: string) => { x: number; y: number } | undefined,
|
||||
): CanvasEdge[] {
|
||||
return links.map((link) => {
|
||||
const source = positionOf(link.edge.sourceServiceId);
|
||||
const target = positionOf(link.edge.targetServiceId);
|
||||
// Choose handle sides by relative position so edges flow forward
|
||||
// instead of looping back through the canvas.
|
||||
const forward = !source || !target || source.x <= target.x;
|
||||
|
||||
return {
|
||||
id: link.edge.id,
|
||||
source: link.edge.sourceServiceId,
|
||||
target: link.edge.targetServiceId,
|
||||
sourceHandle: forward ? 's-r' : 's-l',
|
||||
targetHandle: forward ? 't-l' : 't-r',
|
||||
animated: false,
|
||||
data: {
|
||||
reasons: link.reasons,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: 'var(--accent-secondary)',
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
style: {
|
||||
stroke: 'var(--accent-primary)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
className: 'edge-premium',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
|
||||
@@ -210,10 +224,12 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
() => inferAutoConnections(services, variablesByService),
|
||||
[services, variablesByService],
|
||||
);
|
||||
const edges = useMemo(
|
||||
() => toFlowEdges(inferredLinks).filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)),
|
||||
[inferredLinks, visibleIds],
|
||||
);
|
||||
const edges = useMemo(() => {
|
||||
const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position;
|
||||
return toFlowEdges(inferredLinks, positionOf).filter(
|
||||
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||||
);
|
||||
}, [inferredLinks, visibleIds, nodes]);
|
||||
|
||||
const invalidateServices = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||
@@ -231,6 +247,14 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
},
|
||||
onError: actionError,
|
||||
});
|
||||
const startMutation = useMutation({
|
||||
mutationFn: (serviceId: string) => startService(serviceId),
|
||||
onSuccess: () => {
|
||||
toast.showToast('Service started', 'success');
|
||||
invalidateServices();
|
||||
},
|
||||
onError: actionError,
|
||||
});
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: (serviceId: string) => restartService(serviceId),
|
||||
onSuccess: () => {
|
||||
@@ -256,7 +280,11 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
onError: actionError,
|
||||
});
|
||||
const actionPending =
|
||||
deployMutation.isPending || restartMutation.isPending || stopMutation.isPending || deleteMutation.isPending;
|
||||
deployMutation.isPending ||
|
||||
startMutation.isPending ||
|
||||
restartMutation.isPending ||
|
||||
stopMutation.isPending ||
|
||||
deleteMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
|
||||
@@ -453,11 +481,11 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
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 menuW = 190;
|
||||
const menuH = 230;
|
||||
const x = Math.min(event.clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW));
|
||||
const y = Math.min(event.clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH));
|
||||
setContextMenu({ serviceId: node.id, x, y });
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -635,15 +663,15 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
<ArrowUpRight size={13} />
|
||||
Open service
|
||||
</button>
|
||||
{contextService.domain && (
|
||||
{(contextService.domain || contextService.publicUrl) && (
|
||||
<a
|
||||
href={`https://${contextService.domain}`}
|
||||
href={contextService.domain ? `https://${contextService.domain}` : contextService.publicUrl}
|
||||
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}
|
||||
Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
@@ -658,6 +686,20 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
<Rocket size={13} />
|
||||
Deploy
|
||||
</button>
|
||||
{contextService.status !== 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
startMutation.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} />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || contextService.status !== 'running'}
|
||||
|
||||
@@ -47,6 +47,8 @@ function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'Online';
|
||||
case 'degraded':
|
||||
return 'Degraded';
|
||||
case 'building':
|
||||
return 'Deploying';
|
||||
case 'failed':
|
||||
@@ -56,11 +58,25 @@ function statusLabel(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
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)';
|
||||
}
|
||||
}
|
||||
|
||||
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
const { service, selected } = data;
|
||||
const typeColor = serviceTypeColor(service.type);
|
||||
const isRunning = service.status === 'running';
|
||||
const replicas = service.replicas ?? 0;
|
||||
const stateColor = statusColor(service.status);
|
||||
|
||||
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
||||
@@ -79,12 +95,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
style={{ background: `${typeColor}10` }}
|
||||
/>
|
||||
|
||||
<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" />
|
||||
<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 ${isRunning ? 'opacity-100' : 'opacity-40'}`}
|
||||
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
|
||||
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 p-4">
|
||||
@@ -148,14 +166,15 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
<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"
|
||||
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
|
||||
>
|
||||
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}>
|
||||
{statusLabel(service.status)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--text-tertiary)]">Right-click for actions</span>
|
||||
{service.image && (
|
||||
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}>
|
||||
{service.image}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user