mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
small fix, don't worry about it
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user