mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user