small fix, don't worry about it

This commit is contained in:
Tomas Dvorak committed 2026-04-10 12:02:36 +02:00
1 parent 08bd0c6e5c
commit 08cb5754f3
638 files changed
+57330 -34704

No files matched your search

@@ -0,0 +1,202 @@
import type { ServiceEntity } from '@/lib/api-client';
import type { CanvasEdge } from './model';
export type ServiceVariable = {
key: string;
value: string;
isSecret: boolean;
};
type LinkRecord = {
edge: CanvasEdge;
reasons: string[];
};
function normalizeToken(raw: string): string {
return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
function slugifyServiceName(name: string): string {
return normalizeToken(name);
}
function tokenizePlaceholders(value: string): string[] {
if (!value || typeof value !== 'string') {
return [];
}
const matches = value.match(/\{\{\s*([^{}]+?)\s*\}\}/g);
if (!matches) {
return [];
}
return matches
.map((match) => match.replace(/\{\{|\}\}/g, '').trim())
.map(normalizeToken)
.filter(Boolean);
}
function candidateTokensForService(service: ServiceEntity): Set<string> {
const slug = slugifyServiceName(service.name);
const tokens = new Set<string>([
slug,
`${slug}_url`,
`${slug}_uri`,
`${slug}_host`,
`${slug}_port`,
`${slug}_database_url`,
]);
if (service.type === 'database') {
tokens.add('database_url');
tokens.add('database_host');
tokens.add('database_port');
if (slug.includes('postgres') || slug.includes('pg')) {
tokens.add('postgres');
tokens.add('postgres_url');
tokens.add('postgres_host');
tokens.add('postgres_port');
tokens.add('db_url');
tokens.add('db_host');
tokens.add('db_port');
}
if (slug.includes('redis')) {
tokens.add('redis');
tokens.add('redis_url');
tokens.add('redis_host');
tokens.add('redis_port');
tokens.add('cache_url');
}
if (slug.includes('mysql')) {
tokens.add('mysql');
tokens.add('mysql_url');
tokens.add('mysql_host');
tokens.add('mysql_port');
}
if (slug.includes('mongo')) {
tokens.add('mongo');
tokens.add('mongodb');
tokens.add('mongodb_url');
tokens.add('mongo_url');
}
}
return tokens;
}
function buildTokenIndex(services: ServiceEntity[]): Map<string, string[]> {
const index = new Map<string, string[]>();
const push = (token: string, serviceId: string) => {
const values = index.get(token);
if (values) {
if (!values.includes(serviceId)) {
values.push(serviceId);
}
return;
}
index.set(token, [serviceId]);
};
const databaseServices = services.filter((service) => service.type === 'database');
const primaryDatabase = databaseServices[0];
for (const service of services) {
for (const token of candidateTokensForService(service)) {
push(token, service.id);
}
}
if (primaryDatabase) {
for (const genericToken of ['db', 'db_url', 'db_host', 'db_port']) {
push(genericToken, primaryDatabase.id);
}
}
return index;
}
function resolveTokenTarget(token: string, sourceService: ServiceEntity, services: ServiceEntity[], index: Map<string, string[]>): string[] {
const exact = index.get(token) ?? [];
const resolvedExact = exact.filter((candidate) => candidate !== sourceService.id);
if (resolvedExact.length > 0) {
return resolvedExact;
}
const potential = services
.filter((service) => service.id !== sourceService.id)
.filter((service) => {
const slug = slugifyServiceName(service.name);
return token.includes(slug) && (token.endsWith('url') || token.endsWith('uri') || token.endsWith('host') || token.endsWith('port'));
})
.map((service) => service.id);
if (potential.length > 0) {
return potential;
}
if (token.startsWith('db_') || token === 'database_url') {
const dbServices = services.filter((service) => service.id !== sourceService.id && service.type === 'database');
if (dbServices.length === 1) {
return [dbServices[0].id];
}
}
return [];
}
export function inferAutoConnections(
services: ServiceEntity[],
variablesByService: Record<string, ServiceVariable[]>,
): LinkRecord[] {
if (services.length === 0) {
return [];
}
const serviceMap = new Map(services.map((service) => [service.id, service]));
const tokenIndex = buildTokenIndex(services);
const linkMap = new Map<string, LinkRecord>();
for (const service of services) {
const variables = variablesByService[service.id] ?? [];
for (const variable of variables) {
const tokens = tokenizePlaceholders(variable.value);
for (const token of tokens) {
const targetIds = resolveTokenTarget(token, service, services, tokenIndex);
for (const targetId of targetIds) {
const targetService = serviceMap.get(targetId);
if (!targetService || targetService.id === service.id) {
continue;
}
const key = `${service.id}->${targetService.id}`;
const existing = linkMap.get(key);
const reason = `${variable.key}:{{${token}}}`;
if (existing) {
if (!existing.reasons.includes(reason)) {
existing.reasons.push(reason);
}
continue;
}
linkMap.set(key, {
edge: {
id: `auto-${service.id}-${targetService.id}`,
sourceServiceId: service.id,
targetServiceId: targetService.id,
},
reasons: [reason],
});
}
}
}
}
return Array.from(linkMap.values());
}
@@ -0,0 +1,467 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
import {
Background,
MarkerType,
ReactFlow,
ReactFlowProvider,
useNodesState,
useReactFlow,
type Edge,
type Node,
type NodeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { ServiceEntity } from '@/lib/api-client';
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
import {
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from '../model';
import { loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
type CanvasProps = {
projectId: string;
services: ServiceEntity[];
variablesByService: Record<string, ServiceVariable[]>;
onAddService: () => void;
onOpenService: (serviceId: string) => void;
};
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
type CanvasNode = ServiceCanvasNode | GroupCanvasNode;
type CanvasEdge = Edge<{ reasons: string[] }>;
const SERVICE_NODE_WIDTH = 210;
const SERVICE_NODE_HEIGHT = 96;
const GROUP_DEFAULT_WIDTH = 340;
const GROUP_DEFAULT_HEIGHT = 230;
const nodeTypes: NodeTypes = {
serviceNode: ServiceNode,
groupNode: GroupNode,
};
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] {
const groups = metadata.groups.map(
(group): GroupCanvasNode => ({
id: group.id,
type: 'groupNode',
data: { title: group.title },
position: group.position,
draggable: true,
selectable: true,
style: {
width: group.width,
height: group.height,
},
}),
);
const layoutMap = new Map(metadata.nodes.map((layout) => [layout.serviceId, layout]));
const groupIds = new Set(groups.map((group) => group.id));
const serviceNodes = services.map((service): ServiceCanvasNode => {
const layout = layoutMap.get(service.id);
const parentId = layout?.groupId && groupIds.has(layout.groupId) ? layout.groupId : undefined;
return {
id: service.id,
type: 'serviceNode',
position: layout?.position ?? { x: 0, y: 0 },
parentId,
extent: parentId ? 'parent' : undefined,
data: {
service,
selected: false,
onOpen: onOpenService,
},
draggable: true,
selectable: true,
style: {
width: SERVICE_NODE_WIDTH,
},
};
});
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: '#e8316a',
strokeWidth: 2,
},
className: 'edge-premium',
}));
}
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
const groups: CanvasGroup[] = [];
const layouts: CanvasNodeLayout[] = [];
for (const node of nodes) {
if (node.type === 'groupNode') {
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
groups.push({
id: node.id,
title: node.data.title,
position: node.position,
width,
height,
});
continue;
}
if (node.type === 'serviceNode') {
layouts.push({
serviceId: node.id,
position: node.position,
groupId: node.parentId,
});
}
}
return {
groups,
nodes: layouts,
edges: [],
viewport,
};
}
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService }: CanvasProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const persistTimeout = useRef<number | null>(null);
const hydratedRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
const [viewportTick, setViewportTick] = useState(0);
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>();
const serviceFingerprint = useMemo(
() => services.map((service) => service.id).sort().join('|'),
[services],
);
const inferredLinks = useMemo(
() => inferAutoConnections(services, variablesByService),
[services, variablesByService],
);
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
useEffect(() => {
const metadata = loadCanvasMetadata(projectId, services);
const nextNodes = toFlowNodes(metadata, services, onOpenService);
setNodes(nextNodes);
window.requestAnimationFrame(() => {
setViewport(metadata.viewport, { duration: 120 });
});
hydratedRef.current = true;
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, services]);
useEffect(() => {
if (!hydratedRef.current) {
return;
}
if (persistTimeout.current) {
window.clearTimeout(persistTimeout.current);
}
persistTimeout.current = window.setTimeout(() => {
const metadata = buildMetadataFromFlow(nodes, getViewport());
saveCanvasMetadata(projectId, metadata);
}, 150);
return () => {
if (persistTimeout.current) {
window.clearTimeout(persistTimeout.current);
}
};
}, [getViewport, nodes, projectId, viewportTick]);
useEffect(() => {
setNodes((prev) =>
prev.map((node) => {
if (node.type !== 'serviceNode') {
return node;
}
return {
...node,
data: {
...node.data,
selected: node.id === selectedServiceId,
},
};
}),
);
}, [selectedServiceId, setNodes]);
const getGroupUnderPoint = useCallback((x: number, y: number, ignoreGroupId?: string) => {
for (const node of nodes) {
if (node.type !== 'groupNode' || node.id === ignoreGroupId) {
continue;
}
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
const base = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position;
if (x >= base.x && x <= base.x + width && y >= base.y && y <= base.y + height) {
return node;
}
}
return null;
}, [getInternalNode, nodes]);
const onNodeDragStop = useCallback(
(_event: MouseEvent, movedNode: CanvasNode) => {
if (movedNode.type !== 'serviceNode') {
return;
}
const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position;
const centerX = basePosition.x + SERVICE_NODE_WIDTH / 2;
const centerY = basePosition.y + SERVICE_NODE_HEIGHT / 2;
const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId);
if (targetGroup) {
const groupBase = getInternalNode(targetGroup.id)?.internals.positionAbsolute ?? targetGroup.position;
const targetWidth = typeof targetGroup.style?.width === 'number' ? targetGroup.style.width : GROUP_DEFAULT_WIDTH;
const targetHeight = typeof targetGroup.style?.height === 'number' ? targetGroup.style.height : GROUP_DEFAULT_HEIGHT;
const relativeX = Math.max(12, Math.min(targetWidth - SERVICE_NODE_WIDTH - 12, basePosition.x - groupBase.x));
const relativeY = Math.max(30, Math.min(targetHeight - SERVICE_NODE_HEIGHT - 12, basePosition.y - groupBase.y));
setNodes((current) =>
current.map((node) => {
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
return node;
}
return {
...node,
parentId: targetGroup.id,
extent: 'parent',
position: { x: relativeX, y: relativeY },
};
}),
);
return;
}
if (!movedNode.parentId) {
return;
}
setNodes((current) =>
current.map((node) => {
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
return node;
}
return {
...node,
parentId: undefined,
extent: undefined,
position: {
x: basePosition.x,
y: basePosition.y,
},
};
}),
);
},
[getGroupUnderPoint, getInternalNode, setNodes],
);
const addGroup = useCallback(() => {
const bounds = wrapperRef.current?.getBoundingClientRect();
const center = bounds
? screenToFlowPosition({
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
})
: { x: 180, y: 140 };
const id = `group-${Date.now()}`;
setNodes((current) => [
...current,
{
id,
type: 'groupNode',
position: {
x: center.x - GROUP_DEFAULT_WIDTH / 2,
y: center.y - GROUP_DEFAULT_HEIGHT / 2,
},
data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}` },
draggable: true,
selectable: true,
style: {
width: GROUP_DEFAULT_WIDTH,
height: GROUP_DEFAULT_HEIGHT,
},
} satisfies GroupCanvasNode,
]);
}, [screenToFlowPosition, setNodes]);
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
const selectedServiceLinkCount = selectedService
? inferredLinks.filter(
(link) =>
link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id,
).length
: 0;
return (
<div className="panel overflow-hidden">
{/* Toolbar - Railway-inspired premium design */}
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
<button
type="button"
onClick={onAddService}
className="flex items-center gap-2 h-9 px-4 rounded-lg text-white text-sm font-medium shadow-lg hover:shadow-xl transition-all duration-200"
style={{ background: '#e8316a' }}
>
<Plus size={15} />
Add Service
</button>
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
<button
type="button"
onClick={addGroup}
className="flex items-center gap-2 h-9 px-3 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] hover:border-[var(--border-default)] transition-all"
>
<Layers size={14} />
Group
</button>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={() => fitView({ padding: 0.2, duration: 240 })}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
title="Fit View"
>
<Maximize2 size={16} />
</button>
<button
type="button"
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
title="Reset View"
>
<RotateCcw size={16} />
</button>
</div>
<div className="flex items-center gap-4 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Box size={12} />
<span className="font-medium">{services.length}</span>
</div>
<div className="w-px h-3 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Link2 size={12} />
<span className="font-medium">{edges.length}</span>
</div>
</div>
</div>
{/* Canvas */}
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)]">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onPaneClick={() => setSelectedServiceId(null)}
onNodeClick={(_event, node) => {
if (node.type === 'serviceNode') {
setSelectedServiceId(node.id);
}
}}
onNodeDoubleClick={(_event, node) => {
if (node.type === 'serviceNode') {
onOpenService(node.id);
}
}}
onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)}
fitView
panOnDrag
zoomOnScroll
minZoom={0.25}
maxZoom={2.3}
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
{/* SVG Definitions for premium edge styling */}
<svg style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
<defs />
</svg>
<Background color="rgba(255,255,255,0.05)" gap={28} />
</ReactFlow>
</div>
{/* Footer */}
<div className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
{selectedService ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Box size={14} className="text-[var(--accent-primary)]" />
</div>
<div>
<span className="text-sm font-medium text-[var(--text-primary)]">{selectedService.name}</span>
<span className="ml-2 text-xs text-[var(--text-tertiary)]">({selectedService.type})</span>
</div>
</div>
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-[var(--surface-muted)] text-xs text-[var(--text-tertiary)]">
<Link2 size={10} />
<span>{selectedServiceLinkCount} connection{selectedServiceLinkCount !== 1 ? 's' : ''}</span>
</div>
</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
</p>
)}
</div>
</div>
);
}
export function ProjectCanvas(props: CanvasProps) {
return (
<ReactFlowProvider>
<CanvasInner {...props} />
</ReactFlowProvider>
);
}
@@ -0,0 +1,191 @@
import type { Node, 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 { useState } from 'react';
export type ServiceNodeData = {
service: ServiceEntity;
selected: boolean;
onOpen: (serviceId: string) => void;
};
export type GroupNodeData = {
title: string;
};
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
function serviceTypeIcon(type: string): typeof Box {
switch (type) {
case 'web':
return Globe;
case 'database':
return Database;
case 'worker':
return Terminal;
default:
return Box;
}
}
function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#6c8ef0'; // Blue
case 'database':
return '#9c7ef0'; // Purple
case 'worker':
return '#e8316a'; // Pink
default:
return '#9295a4'; // Gray
}
}
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected, onOpen } = data;
const iconType = serviceTypeIcon(service.type);
const typeColor = serviceTypeColor(service.type);
const isRunning = service.status === 'running';
// Generate a mock domain for display (in real app, this would come from service data)
const domain = `${service.name}.containr.local`;
return (
<div
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg'
}`}
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` }}
/>
{/* Status indicator bar at top */}
<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
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={{
background: selected ? typeColor : `${typeColor}20`,
color: selected ? 'white' : typeColor
}}
>
{iconType === Globe && <Globe size={18} />}
{iconType === Database && <Database size={18} />}
{iconType === Terminal && <Terminal size={18} />}
{iconType === Box && <Box 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>
</div>
</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">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="text-[10px] text-[var(--text-secondary)] truncate mono">{domain}</span>
</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"
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
>
{isRunning ? 'Online' : 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-white 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>
</div>
</div>
</div>
);
}
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30'
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Ambient glow on hover */}
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
style={{
background: 'rgba(232,49,106,0.03)',
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
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>
<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 }}>
Drag services here to group them
</p>
</div>
</div>
);
}
@@ -0,0 +1,306 @@
import { useEffect, useState } from 'react';
import {
LineMetricChart,
DualLineChart,
DonutChart,
} from '@/shared/components';
interface MetricData {
cpu: number[];
ram: number;
ramUsed: string;
cache: number;
cacheBreakdown: { cache: number; nonCache: number; total: number };
users: number[];
performance: number[];
performanceAlt: number[];
upSpeed: number;
downSpeed: number;
}
interface MetricsDashboardProps {
projectId?: string;
isRunning?: boolean;
onStop?: () => void;
onRestart?: () => void;
}
function getRandom(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function generateInitialData(): MetricData {
return {
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)),
ram: getRandom(50, 85),
ramUsed: '5.4 GB',
cache: 352,
cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
users: Array.from({ length: 15 }, () => getRandom(40, 110)),
performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
upSpeed: 10.4,
downSpeed: 5.2,
};
}
export function MetricsDashboard({
isRunning = true,
}: MetricsDashboardProps) {
const [data, setData] = useState<MetricData>(generateInitialData);
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => {
setData((prev) => {
const newCpu = [...prev.cpu.slice(1), getRandom(10, 45)];
const newUsers = [...prev.users.slice(1), getRandom(60, 110)];
const newPerf = [...prev.performance.slice(1), getRandom(82, 99)];
const newPerfAlt = [...prev.performanceAlt.slice(1), getRandom(60, 85)];
return {
...prev,
cpu: newCpu,
ram: getRandom(50, 85),
ramUsed: `${(8 * (prev.ram / 100)).toFixed(1)} GB`,
users: newUsers,
performance: newPerf,
performanceAlt: newPerfAlt,
upSpeed: parseFloat((Math.random() * 5 + 8).toFixed(1)),
downSpeed: parseFloat((Math.random() * 3 + 4).toFixed(1)),
};
});
}, 2000);
return () => clearInterval(interval);
}, [isRunning]);
const currentCpu = data.cpu[data.cpu.length - 1];
const currentUsers = data.users[data.users.length - 1];
const currentPerf = data.performance[data.performance.length - 1];
return (
<div>
{/* Metrics Header - self.html exact match */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button style={{
height: '32px',
padding: '0 12px',
borderRadius: '9px',
border: '1px solid rgba(255,255,255,0.09)',
background: 'rgba(255,255,255,0.04)',
color: '#9295a4',
fontSize: '12.5px',
fontWeight: 500,
fontFamily: 'inherit',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filter
</button>
<div className="pill-group">
{(['day', 'month', 'year'] as const).map((range) => (
<div
key={range}
className={`pill ${timeRange === range ? 'active' : ''}`}
onClick={() => setTimeRange(range)}
>
{range.charAt(0).toUpperCase() + range.slice(1)}
</div>
))}
</div>
</div>
</div>
{/* Row 1: CPU, RAM, Cache - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
{/* CPU Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>CPU Usage</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentCpu}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentCpu < 50 ? '#3dd68c' : currentCpu < 75 ? '#f0a040' : '#ff7043', fontWeight: 700 }}>
{currentCpu < 50 ? 'Good' : currentCpu < 75 ? 'Average' : 'High'}
</span>{' '}
Daily usage
</div>
<div style={{ height: 76, margin: '12px 0 6px' }}>
<LineMetricChart data={data.cpu} color="#ff7043" height={76} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
{/* RAM Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 8V6M10 8V6M14 8V6M18 8V6M6 16v2M18 16v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.ram}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: data.ram < 60 ? '#3dd68c' : '#f0a040', fontWeight: 700 }}>
{data.ram < 60 ? 'Good' : 'Average'}
</span>{' '}
Daily usage
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percent={data.ram} color="#9c7ef0" size={160} thickness={16} />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: '10.5px', color: '#6b6e7d', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: '12.5px', fontWeight: 700, color: '#e8e9f0' }}>{data.ramUsed} / 8GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
{/* Cache Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.cache} MB</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: '#f0a040', fontWeight: 700 }}>220MB Average</span>{' '}
cached images and files
</div>
{/* Segmented Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', margin: '16px 0 15px', height: '32px' }}>
<div className="cache-seg" style={{ width: '43%', background: '#ff6b5b', borderRadius: '10px 4px 4px 10px' }} />
<div className="cache-seg" style={{ width: '13%', background: '#8c6ef0', borderRadius: '5px' }} />
<div className="cache-seg" style={{ flex: 1, background: 'rgba(255,255,255,0.07)', borderRadius: '4px 10px 10px 4px' }} />
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr auto 1fr', gap: 0, alignItems: 'stretch' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} />
Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>212 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>12%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} />
Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>85.5 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>4%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>Total</div>
<div style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>1.75 GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '16px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
</div>
{/* Row 2: Active Users, Performance - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
{/* Active Users - horizontal layout */}
<div className="card" style={{ flexDirection: 'row', padding: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, padding: '20px 18px 18px 20px', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Active User</span>
</div>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentUsers} K</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>User active right now</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', marginTop: '12px', flexWrap: 'wrap' }}>
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
<span key={i} className="flag">{flag}</span>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
<div style={{ width: '50%', padding: '16px 14px 46px 0', display: 'flex', alignItems: 'flex-end' }}>
<LineMetricChart data={data.users} color="#e8316a" fillOpacity={0.15} showArea height={130} />
</div>
</div>
{/* Performance Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '16px', flex: 1 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentPerf}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentPerf > 85 ? '#3dd68c' : '#f0a040', fontWeight: 700 }}>
{currentPerf > 85 ? 'Good' : 'Average'}
</span>{' '}
Last scan
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
<div style={{ width: 134, height: 58 }}>
<DualLineChart data1={data.performance} data2={data.performanceAlt} height={58} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end' }}>
<div className="speed-row" style={{ color: '#6c8ef0' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 19 19 12"/></svg>
<span>{data.upSpeed}</span> Mbps
</div>
<div className="speed-row" style={{ color: '#e8316a' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 5 5 12"/></svg>
<span>{data.downSpeed}</span> Mbps
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Check Speed</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import type { ServiceEntity } from '@/lib/api-client';
export type CanvasPoint = {
x: number;
y: number;
};
export type CanvasGroup = {
id: string;
title: string;
position: CanvasPoint;
width: number;
height: number;
};
export type CanvasNodeLayout = {
serviceId: string;
position: CanvasPoint;
groupId?: string;
};
export type CanvasEdge = {
id: string;
sourceServiceId: string;
targetServiceId: string;
};
export type CanvasViewport = {
x: number;
y: number;
zoom: number;
};
export type ProjectCanvasMetadata = {
groups: CanvasGroup[];
nodes: CanvasNodeLayout[];
edges: CanvasEdge[];
viewport: CanvasViewport;
};
export const DEFAULT_VIEWPORT: CanvasViewport = {
x: 0,
y: 0,
zoom: 1,
};
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata {
const nodes = services.map((service, index) => {
const col = index % 3;
const row = Math.floor(index / 3);
return {
serviceId: service.id,
position: {
x: 70 + col * 260,
y: 80 + row * 170,
},
};
});
return {
groups: [],
nodes,
edges: [],
viewport: DEFAULT_VIEWPORT,
};
}
@@ -0,0 +1,667 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createService,
getProjectById,
listServiceLogs,
listServiceVariables,
listServicesByProject,
type CreateServiceInput,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
import { formatDate, formatRelative } from '@/lib/time';
import type { ServiceVariable } from '../auto-connections';
import { ProjectCanvas } from '../canvas/ProjectCanvas';
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
import { MetricsDashboard } from '../components/MetricsDashboard';
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
import {
ArrowLeft,
LayoutGrid,
Activity,
FileText,
Settings,
Plus,
X,
Loader2,
Layers,
Clock,
Trash2,
Sparkles,
Box,
Search,
} from 'lucide-react';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid }> = [
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid },
{ key: 'observability', label: 'Observability', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText },
{ key: 'settings', label: 'Settings', icon: Settings },
];
const serviceTypes: Array<CreateServiceInput['type']> = ['web', 'worker', 'database', 'cron'];
const serviceEnvironments = ['production', 'preview', 'development'] as const;
function ServiceCreateDialog(props: {
open: boolean;
onClose: () => void;
onSubmit: (payload: CreateServiceInput) => void;
loading: boolean;
errorMessage?: string;
}) {
const [form, setForm] = useState<CreateServiceInput>({
name: '',
type: 'web',
environment: 'production',
image: '',
command: '',
});
if (!props.open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={props.onClose} />
<div className="relative w-full max-w-lg panel p-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Plus size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Add Service</h3>
</div>
<p className="text-sm text-[var(--text-secondary)]">
Deploy a new service to this project. Configure runtime settings after creation.
</p>
<div className="mt-6 space-y-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Service Name
</label>
<input
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
placeholder="api-gateway"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Type
</label>
<select
value={form.type}
onChange={(e) => setForm((p) => ({ ...p, type: e.target.value as CreateServiceInput['type'] }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
>
{serviceTypes.map((type) => (
<option key={type} value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Environment
</label>
<select
value={form.environment}
onChange={(e) => setForm((p) => ({ ...p, environment: e.target.value as typeof serviceEnvironments[number] }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
>
{serviceEnvironments.map((env) => (
<option key={env} value={env}>{env.charAt(0).toUpperCase() + env.slice(1)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.image ?? ''}
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="ghcr.io/org/app:latest"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Command <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.command ?? ''}
onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="npm run start"
/>
</div>
</div>
{props.errorMessage && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{props.errorMessage}
</div>
)}
<div className="mt-6 flex justify-end gap-3">
<button
onClick={props.onClose}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)] text-sm font-medium hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
>
Cancel
</button>
<button
disabled={!form.name.trim() || props.loading}
onClick={() => props.onSubmit({ ...form, name: form.name.trim(), image: form.image?.trim(), command: form.command?.trim() })}
className="px-5 py-2 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>
{props.loading ? 'Creating...' : 'Create Service'}
</button>
</div>
</div>
</div>
);
}
export function ProjectWorkspacePage() {
const { projectId = '' } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const toast = useToast();
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
const isDemoMode = searchParams.get('demo') === '1';
const [createOpen, setCreateOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setCommandPaletteOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
const projectQuery = useQuery({
queryKey: ['project', projectId],
queryFn: () => getProjectById(projectId),
enabled: Boolean(projectId) && !isDemoMode,
});
const servicesQuery = useQuery({
queryKey: ['project-services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId) && !isDemoMode,
});
const createServiceMutation = useMutation({
mutationFn: (payload: CreateServiceInput) => createService(projectId, payload),
onSuccess: () => {
setCreateOpen(false);
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
});
const project = useMemo(
() => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data),
[isDemoMode, projectId, projectQuery.data],
);
const services = useMemo(
() => (isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []),
[isDemoMode, projectId, servicesQuery.data],
);
const serviceIdFingerprint = services.map((service) => service.id).sort().join('|');
const variablesQuery = useQuery({
queryKey: ['project-service-vars', projectId, serviceIdFingerprint],
queryFn: async () => {
const entries = await Promise.all(
services.map(async (service) => {
const variables = await listServiceVariables(service.id);
return [service.id, variables.map((variable) => ({
key: variable.key,
value: variable.value,
isSecret: variable.isSecret,
}))] as const;
}),
);
return Object.fromEntries(entries) as Record<string, ServiceVariable[]>;
},
enabled: !isDemoMode && services.length > 0,
});
const variablesByService = isDemoMode ? getDemoVariablesByProject(projectId) : variablesQuery.data ?? {};
const workspaceLogsQuery = useQuery({
queryKey: ['workspace-service-logs', projectId, serviceIdFingerprint],
queryFn: async () => {
const rows = await Promise.all(
services.map(async (service) => {
const logs = await listServiceLogs(service.id, { tail: '20' });
return logs.map((entry) => ({
serviceId: service.id,
serviceName: service.name,
stream: entry.stream,
message: entry.message,
timestamp: entry.timestamp,
}));
}),
);
return rows
.flat()
.sort((left, right) => {
const leftValue = left.timestamp ? new Date(left.timestamp).getTime() : 0;
const rightValue = right.timestamp ? new Date(right.timestamp).getTime() : 0;
return rightValue - leftValue;
})
.slice(0, 200);
},
enabled: !isDemoMode && activeView === 'logs' && services.length > 0,
});
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
const serviceHref = (serviceId: string) =>
isDemoMode
? `/projects/${projectId}/services/${serviceId}?demo=1`
: `/projects/${projectId}/services/${serviceId}`;
if (!isDemoMode && projectQuery.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-[var(--text-secondary)]">
<Loader2 size={20} className="animate-spin" />
<span>Loading workspace...</span>
</div>
</div>
);
}
if ((!isDemoMode && projectQuery.isError) || !project) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Project not found</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
This project may have been deleted or you don't have access.
</p>
<button
onClick={() => navigate('/projects')}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Back to Projects
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/projects')}
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
>
<ArrowLeft size={16} />
<span>Projects</span>
</button>
<div className="w-px h-5 bg-[var(--border-subtle)]" />
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)]">{project.name}</h1>
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
</div>
</div>
<div className="flex items-center gap-6">
{/* Stats */}
<div className="hidden md:flex items-center gap-6">
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
</div>
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<LiveIndicator isLive={runningServices > 0} />
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Clock size={12} />
<span>{formatRelative(project.updatedAt)}</span>
</div>
</div>
{/* Search / Command Palette */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={14} />
<span>Search...</span>
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{/* Add Service Button */}
{!isDemoMode && (
<button
onClick={() => setCreateOpen(true)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg hover:shadow-xl transition-all"
style={{ background: '#e8316a' }}
>
<Plus size={16} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div>
</div>
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data for preview</span>
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
{/* Sidebar Navigation */}
<aside className="lg:sticky lg:top-6 lg:h-fit">
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
{viewItems.map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
active
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
}`}
>
<Icon size={18} />
<span className="text-sm font-medium">{item.label}</span>
{active && (
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
)}
</button>
);
})}
</nav>
</aside>
{/* Content Area */}
<section className="min-w-0">
{activeView === 'canvas' && (
<ProjectCanvas
projectId={project.id}
services={services}
variablesByService={variablesByService}
onAddService={() => setCreateOpen(true)}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
/>
)}
{activeView === 'observability' && (
<div className="space-y-6">
{/* Health Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)] live-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Healthy</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--success)]">{runningServices}</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--error)]">
{services.filter((s) => s.status === 'failed').length}
</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Building</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--warning)]">
{services.filter((s) => s.status === 'building').length}
</p>
</div>
</div>
{/* Metrics Dashboard */}
<MetricsDashboard
isRunning={runningServices > 0}
onStop={() => toast.showToast('Services stopped', 'warning')}
onRestart={() => toast.showToast('Services restarted', 'success')}
/>
{/* Service List */}
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Services</h3>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
{services.length === 0 ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Layers size={20} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-secondary)]">No services deployed yet</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>
</div>
) : (
services.map((service) => (
<button
key={service.id}
onClick={() => navigate(serviceHref(service.id))}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Box size={14} className="text-[var(--text-tertiary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p>
<p className="text-xs text-[var(--text-tertiary)]">{service.type}</p>
</div>
</div>
<StatusBadge status={service.status} />
</button>
))
)}
</div>
</div>
</div>
)}
{activeView === 'logs' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Logs</h2>
<p className="text-sm text-[var(--text-secondary)]">Aggregated service output</p>
</div>
</div>
{!isDemoMode && (
<button
onClick={() => workspaceLogsQuery.refetch()}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
>
Refresh
</button>
)}
</div>
<div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto">
{services.length === 0 ? (
<p className="text-[var(--text-muted)]">No services available.</p>
) : isDemoMode ? (
<div className="space-y-1">
{services.slice(0, 10).map((service, i) => (
<p key={service.id}>
<span className="text-[var(--text-muted)]">
[{service.updatedAt ? new Date(new Date(service.updatedAt).getTime() - i * 60000).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{service.name}</span>
{' '}
<span className="text-[var(--text-tertiary)]">status={service.status}</span>
</p>
))}
</div>
) : workspaceLogsQuery.isLoading ? (
<p className="text-[var(--text-muted)]">Loading logs...</p>
) : workspaceLogsQuery.isError ? (
<p className="text-[var(--error)]">Failed to load logs.</p>
) : (workspaceLogsQuery.data?.length ?? 0) === 0 ? (
<p className="text-[var(--text-muted)]">No recent logs.</p>
) : (
<div className="space-y-1">
{workspaceLogsQuery.data!.map((entry, i) => (
<p key={`${entry.serviceId}-${entry.timestamp}-${i}`}>
<span className="text-[var(--text-muted)]">
[{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{entry.serviceName}</span>
{' '}
<span className="text-[var(--text-tertiary)]">{entry.stream}</span>
{' '}
{entry.message}
</p>
))}
</div>
)}
</div>
</div>
)}
{activeView === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<p className="text-sm text-[var(--text-secondary)]">Project configuration</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p>
</div>
<div className="panel-soft p-4 md:col-span-2">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p>
<p className="mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
<button
onClick={() => {
clearCanvasMetadata(project.id);
window.location.reload();
}}
className="flex items-center gap-2 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] transition-colors"
>
<Trash2 size={16} />
Reset Canvas Layout
</button>
</div>
</div>
)}
</section>
</div>
</div>
{/* Create Service Dialog */}
{!isDemoMode && (
<ServiceCreateDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
loading={createServiceMutation.isPending}
errorMessage={createServiceMutation.error ? (createServiceMutation.error as Error).message : undefined}
onSubmit={(payload) => createServiceMutation.mutate(payload)}
/>
)}
{/* Command Palette */}
<CommandPalette
open={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onAddService={(type) => {
setCreateOpen(true);
toast.showToast(`Creating ${type} service...`, 'info');
}}
onNavigate={(path) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', path);
return next;
});
}}
/>
{/* Error Banner */}
{!isDemoMode && (servicesQuery.isError || variablesQuery.isError) && (
<div className="fixed bottom-4 right-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
Unable to load services for this project.
</div>
)}
</div>
);
}
@@ -0,0 +1,236 @@
import type { ServiceEntity } from '@/lib/api-client';
import {
createDefaultCanvasMetadata,
DEFAULT_VIEWPORT,
type CanvasEdge,
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from './model';
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function parsePoint(value: unknown): { x: number; y: number } | null {
if (!value || typeof value !== 'object') {
return null;
}
const candidate = value as Record<string, unknown>;
if (!isFiniteNumber(candidate.x) || !isFiniteNumber(candidate.y)) {
return null;
}
return { x: candidate.x, y: candidate.y };
}
function parseGroups(raw: unknown): CanvasGroup[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const group = entry as Record<string, unknown>;
const point = parsePoint(group.position);
if (!point || typeof group.id !== 'string' || typeof group.title !== 'string') {
return null;
}
const width = isFiniteNumber(group.width) ? group.width : 320;
const height = isFiniteNumber(group.height) ? group.height : 220;
return {
id: group.id,
title: group.title,
width,
height,
position: point,
} satisfies CanvasGroup;
})
.filter((entry): entry is CanvasGroup => entry !== null);
}
function parseNodes(raw: unknown): CanvasNodeLayout[] {
if (!Array.isArray(raw)) {
return [];
}
const parsed: CanvasNodeLayout[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') {
continue;
}
const node = entry as Record<string, unknown>;
const point = parsePoint(node.position);
if (!point || typeof node.serviceId !== 'string') {
continue;
}
const normalized: CanvasNodeLayout = {
serviceId: node.serviceId,
position: point,
};
if (typeof node.groupId === 'string') {
normalized.groupId = node.groupId;
}
parsed.push(normalized);
}
return parsed;
}
function parseEdges(raw: unknown): CanvasEdge[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const edge = entry as Record<string, unknown>;
if (
typeof edge.id !== 'string' ||
typeof edge.sourceServiceId !== 'string' ||
typeof edge.targetServiceId !== 'string'
) {
return null;
}
return {
id: edge.id,
sourceServiceId: edge.sourceServiceId,
targetServiceId: edge.targetServiceId,
} satisfies CanvasEdge;
})
.filter((entry): entry is CanvasEdge => entry !== null);
}
function parseViewport(raw: unknown): ProjectCanvasMetadata['viewport'] {
if (!raw || typeof raw !== 'object') {
return DEFAULT_VIEWPORT;
}
const viewport = raw as Record<string, unknown>;
if (!isFiniteNumber(viewport.x) || !isFiniteNumber(viewport.y) || !isFiniteNumber(viewport.zoom)) {
return DEFAULT_VIEWPORT;
}
return {
x: viewport.x,
y: viewport.y,
zoom: viewport.zoom,
};
}
function parseRawCanvasMetadata(raw: unknown): ProjectCanvasMetadata | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const source = raw as Record<string, unknown>;
return {
groups: parseGroups(source.groups),
nodes: parseNodes(source.nodes),
edges: parseEdges(source.edges),
viewport: parseViewport(source.viewport),
};
}
export function canvasStorageKey(projectId: string): string {
return `containr.canvas.v1.${projectId}`;
}
export function clearCanvasMetadata(projectId: string): void {
localStorage.removeItem(canvasStorageKey(projectId));
}
export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMetadata): void {
localStorage.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
}
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata {
const key = canvasStorageKey(projectId);
const fallback = createDefaultCanvasMetadata(services);
const serviceIds = new Set(services.map((service) => service.id));
const raw = localStorage.getItem(key);
if (!raw) {
saveCanvasMetadata(projectId, fallback);
return fallback;
}
try {
const parsed = parseRawCanvasMetadata(JSON.parse(raw));
if (!parsed) {
saveCanvasMetadata(projectId, fallback);
return fallback;
}
const validGroups = parsed.groups;
const groupIds = new Set(validGroups.map((group) => group.id));
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
const normalizedNodes: CanvasNodeLayout[] = [];
for (const [index, service] of services.entries()) {
const existing = nodeMap.get(service.id);
if (existing) {
normalizedNodes.push({
serviceId: existing.serviceId,
position: existing.position,
groupId: existing.groupId && groupIds.has(existing.groupId) ? existing.groupId : undefined,
});
continue;
}
const col = index % 3;
const row = Math.floor(index / 3);
normalizedNodes.push({
serviceId: service.id,
position: {
x: 70 + col * 260,
y: 80 + row * 170,
},
});
}
const seenEdgeIds = new Set<string>();
const normalizedEdges = parsed.edges.filter((edge) => {
if (
!serviceIds.has(edge.sourceServiceId) ||
!serviceIds.has(edge.targetServiceId) ||
edge.sourceServiceId === edge.targetServiceId ||
seenEdgeIds.has(edge.id)
) {
return false;
}
seenEdgeIds.add(edge.id);
return true;
});
const normalized: ProjectCanvasMetadata = {
groups: validGroups,
nodes: normalizedNodes,
edges: normalizedEdges,
viewport: parseViewport(parsed.viewport),
};
saveCanvasMetadata(projectId, normalized);
return normalized;
} catch {
saveCanvasMetadata(projectId, fallback);
return fallback;
}
}