diff --git a/.gitignore b/.gitignore index a547bf3..e00baab 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + + +remote/ diff --git a/README.md b/README.md index 7bf8fa6..0267589 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,26 @@ # App Graph Builder A responsive infrastructure graph editor built with React, TypeScript, -ReactFlow, shadcn/ui, TanStack Query, Zustand, and Mock Service Worker. +React Flow, Tailwind CSS, shadcn/ui, TanStack Query, Zustand, and Mock Service +Worker. -## Features +## Setup Instructions -- Screenshot-inspired top bar, icon rail, right panel, and dotted canvas -- Three-node application graphs with dragging, selection, deletion, zoom, pan, - fit view, and edge creation -- Config and Runtime inspector tabs -- Status badge, editable node fields, and synchronized capacity controls -- MSW endpoints for `GET /api/apps` and `GET /api/apps/:appId/graph` -- TanStack Query loading, error, retry, and per-application caching -- Zustand-managed app selection, node selection, drawer state, and inspector tab -- shadcn/ui controls and a mobile Sheet drawer -- Add Node button and keyboard shortcuts +### Prerequisites -## Setup +- Node.js `20.19+` or `22.12+` +- npm + +### Install and run ```bash npm install npm run dev ``` -Open the local URL printed by Vite, normally `http://localhost:5173`. +Open the URL printed by Vite, normally `http://localhost:5173`. -## Validation +### Validate a change ```bash npm run typecheck @@ -33,35 +28,40 @@ npm run lint npm run build ``` -## Keyboard Shortcuts - -- `F`: fit the graph into view -- `P`: toggle the mobile application panel -- `Delete` or `Backspace`: delete selected graph elements - ## Key Decisions -ReactFlow owns mutable node and edge state because its change handlers are -designed around controlled graph arrays. Zustand stores only cross-component UI -state and does not duplicate derived node data. - -TanStack Query owns server-like application and graph data. Its query keys -include the application ID, producing an independent cached graph for each app. +- React Flow owns the editable node and edge arrays so its drag, connect, and + delete handlers work with controlled state. +- Zustand stores cross-component UI state such as application and node + selection, inspector tabs, panel visibility, and theme. +- TanStack Query handles application graph loading and caching by application + ID. +- MSW provides browser-level mock API endpoints without requiring a backend. +- The desktop inspector uses a collapsible workspace; smaller screens reuse the + inspector in a Sheet. Selecting a node opens the relevant panel. +- Service and database nodes use separate React Flow node components while + sharing the same inspector data contract. +- Theme preference is stored in `localStorage`. -MSW intercepts real browser `fetch` requests. This keeps the API client shaped -like production code while still requiring no backend. +## Known Limitations -The desktop inspector is an aside. On smaller screens the same panel content is -rendered in a shadcn Sheet controlled by Zustand. +- Application and graph data are mocked; no production backend is connected. +- Node edits, additions, deletions, positions, and new edges are not persisted. + They reset after a refresh or when graph data is loaded again. +- Runtime values are illustrative rather than live infrastructure metrics. +- The Share button is currently a visual placeholder. +- Authentication, authorization, multi-user collaboration, undo/redo, and + automated tests are not implemented. +- The app depends on the MSW service worker starting successfully before React + is mounted. -## Mock Error Demo +## Keyboard Shortcuts -Use the warning button in the top bar to enable or disable mocked HTTP 500 -responses. Active queries reset so their loading and error states can be tested. +- `F`: fit the graph into view +- `P`: toggle the mobile application panel +- `Delete` or `Backspace`: delete selected graph elements -## Known Limitations +## Mock Error Demo -- Mock data and graph edits reset after a full page refresh. -- Inspector edits are local ReactFlow state and are not submitted to an API. -- The theme and share buttons are visual placeholders. -- Authentication and real-time collaboration are not implemented. +Use the warning button in the top bar to toggle mocked HTTP 500 responses. +Active queries reset so loading, error, and retry states can be tested. diff --git a/src/App.tsx b/src/App.tsx index 4584b03..73f09e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { RightPanel } from "./components/layout/RightPanel"; import { TopBar } from "./components/layout/TopBar"; import { useApplicationGraph } from "./hooks/use-app-graph"; import { useUiStore } from "./store/ui-store"; -import type { ServiceNode } from "./types/graph"; +import type { GraphNode } from "./types/graph"; import { Button } from "./components/ui/button"; function AppContent() { @@ -21,7 +21,7 @@ function AppContent() { const graphQuery = useApplicationGraph(selectedAppId); - const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -38,13 +38,14 @@ function AppContent() { setNodes(structuredClone(graphQuery.data.nodes)); setEdges(structuredClone(graphQuery.data.edges)); - }, [graphQuery.data, setEdges, setNodes]); + setSelectedNodeId(graphQuery.data.nodes[0]?.id ?? null); + }, [graphQuery.data, setEdges, setNodes, setSelectedNodeId]); function addNode() { const nodeId = crypto.randomUUID(); const nodeNumber = nodes.length + 1; - const newNode: ServiceNode = { + const newNode: GraphNode = { id: nodeId, type: "service", position: { @@ -64,22 +65,22 @@ function AppContent() { } return ( -
+
-
+
-
+
{graphQuery.isPending && ( -
-
+
+

Loading application graph...

)} {graphQuery.isError && ( -
+

Unable to load graph

diff --git a/src/components/graph/DatabaseNode.tsx b/src/components/graph/DatabaseNode.tsx new file mode 100644 index 0000000..28ae1e1 --- /dev/null +++ b/src/components/graph/DatabaseNode.tsx @@ -0,0 +1,105 @@ +import { Database, HardDrive } from "lucide-react"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; + +import type { DatabaseGraphNode } from "@/types/graph"; +import type { MetricProps } from "@/types/components"; + +const statusStyles = { + healthy: { label: "Healthy", className: "bg-green-500/20 text-green-500" }, + degraded: { + label: "Degraded", + className: "bg-yellow-400/20 text-yellow-500", + }, + down: { label: "Down", className: "bg-red-500/20 text-red-500" }, +} as const; + +function Metric({ + label, + value, + icon: Icon, +}: MetricProps) { + return ( +
+ + {Icon && } + {label} + + {value} +
+ ); +} + +export function DatabaseNode({ + data, + selected, +}: NodeProps) { + const status = statusStyles[data.status]; + + return ( +
+ + +
+
+ + + +
+ {data.name} + + Database + +
+
+ + + + +
+ +

+ {data.description} +

+ +
+ + + +
+ +
+ +
+ +
+ + {status.label} + + + Storage + +
+ + +
+ ); +} diff --git a/src/components/graph/GraphCanvas.tsx b/src/components/graph/GraphCanvas.tsx index 17c0b99..f4e0a09 100644 --- a/src/components/graph/GraphCanvas.tsx +++ b/src/components/graph/GraphCanvas.tsx @@ -1,22 +1,24 @@ -import type { Dispatch, SetStateAction } from "react"; -import type { ServiceNode } from "@/types/graph"; +import { useEffect, useRef } from "react"; +import type { GraphNode } from "@/types/graph"; +import type { GraphCanvasProps } from "@/types/components"; import { Background, BackgroundVariant, Controls, ReactFlow, + useReactFlow, type Edge, - type OnEdgesChange, - type OnNodesChange, type Connection, addEdge, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { ServiceNode as ServiceNodeComponent } from "@/components/graph/ServiceNode"; +import { DatabaseNode } from "@/components/graph/DatabaseNode"; import { useUiStore } from "@/store/ui-store"; const nodeTypes = { service: ServiceNodeComponent, + database: DatabaseNode, }; const defaultEdgeOptions = { @@ -27,14 +29,6 @@ const defaultEdgeOptions = { }, }; -type GraphCanvasProps = { - nodes: ServiceNode[]; - edges: Edge[]; - setEdges: Dispatch>; - onNodesChange: OnNodesChange; - onEdgesChange: OnEdgesChange; -}; - export function GraphCanvas({ nodes, edges, @@ -43,8 +37,31 @@ export function GraphCanvas({ onEdgesChange, }: GraphCanvasProps) { const setSelectedNodeId = useUiStore((state) => state.setSelectedNodeId); + const setWorkspaceOpen = useUiStore((state) => state.setWorkspaceOpen); + const setMobilePanelOpen = useUiStore((state) => state.setMobilePanelOpen); + const theme = useUiStore((state) => state.theme); + const { fitView } = useReactFlow(); + const hasFittedRef = useRef(false); + + useEffect(() => { + if (nodes.length === 0 || hasFittedRef.current) { + return; + } - function handleNodesDelete(deletedNodes: ServiceNode[]) { + const frameId = window.requestAnimationFrame(() => { + void fitView({ + padding: 0.2, + duration: 300, + }); + hasFittedRef.current = true; + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [fitView, nodes.length]); + + function handleNodesDelete(deletedNodes: GraphNode[]) { const deletedIds = new Set(deletedNodes.map((node) => node.id)); setEdges((currentEdges) => @@ -74,7 +91,7 @@ export function GraphCanvas({ return (
- + nodeTypes={nodeTypes} nodes={nodes} edges={edges} @@ -82,6 +99,11 @@ export function GraphCanvas({ onEdgesChange={onEdgesChange} onNodeClick={(_, node) => { setSelectedNodeId(node.id); + setWorkspaceOpen(true); + + if (window.matchMedia("(max-width: 900px)").matches) { + setMobilePanelOpen(true); + } }} onPaneClick={() => { setSelectedNodeId(null); @@ -97,10 +119,10 @@ export function GraphCanvas({ variant={BackgroundVariant.Dots} gap={24} size={1.5} - color="#334155" + color={theme === "dark" ? "#334155" : "#cbd5e1"} /> - +
); diff --git a/src/components/graph/ServiceNode.tsx b/src/components/graph/ServiceNode.tsx index b90c5fe..b62d719 100644 --- a/src/components/graph/ServiceNode.tsx +++ b/src/components/graph/ServiceNode.tsx @@ -1,6 +1,6 @@ import { Handle, Position, type NodeProps } from "@xyflow/react"; -import { Database, Server } from "lucide-react"; -import type { ServiceNode as ServiceNodeType } from "@/types/graph"; +import { Boxes, Settings2 } from "lucide-react"; +import type { ServiceGraphNode } from "@/types/graph"; const statusStyles = { healthy: { @@ -17,27 +17,27 @@ const statusStyles = { }, } as const; -export function ServiceNode({ data, selected }: NodeProps) { +export function ServiceNode({ data, selected }: NodeProps) { const status = statusStyles[data.status]; return (
- - + + {data.name} @@ -45,33 +45,41 @@ export function ServiceNode({ data, selected }: NodeProps) {
-

{data.description}

+

+ {data.description} +

- CPU + + CPU + {data.capacity}%
- Memory + + Memory + 0.05 GB
- Disk + + Disk + 10 GB
-
+
) {
); diff --git a/src/components/icons/GithubIcon.tsx b/src/components/icons/GithubIcon.tsx index 9c0458e..c264e64 100644 --- a/src/components/icons/GithubIcon.tsx +++ b/src/components/icons/GithubIcon.tsx @@ -1,8 +1,4 @@ -import type { SVGProps } from "react"; - -type GithubIconProps = SVGProps & { - size?: number; -}; +import type { GithubIconProps } from "@/types/components"; export function GithubIcon({ size = 20, diff --git a/src/components/inspector/NodeInspector.tsx b/src/components/inspector/NodeInspector.tsx index 3f14a1c..07b463a 100644 --- a/src/components/inspector/NodeInspector.tsx +++ b/src/components/inspector/NodeInspector.tsx @@ -1,4 +1,3 @@ -import type { Dispatch, SetStateAction } from "react"; import { Activity, Settings2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; @@ -6,13 +5,8 @@ import { Input } from "@/components/ui/input"; import { Slider } from "@/components/ui/slider"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useUiStore } from "@/store/ui-store"; -import type { ServiceNode, ServiceNodeData } from "@/types/graph"; - -type NodeInspectorProps = { - nodes: ServiceNode[]; - setNodes: Dispatch>; - idPrefix?: string; -}; +import type { GraphNodeData } from "@/types/graph"; +import type { NodeInspectorProps } from "@/types/components"; const statusLabels = { healthy: "Healthy", @@ -30,6 +24,7 @@ export function NodeInspector({ nodes, setNodes, idPrefix = "inspector", + compact = false, }: NodeInspectorProps) { const selectedNodeId = useUiStore((state) => state.selectedNodeId); @@ -42,7 +37,7 @@ export function NodeInspector({ const descriptionInputId = `${idPrefix}-node-description`; const capacityInputId = `${idPrefix}-node-capacity`; - function updateSelectedNode(changes: Partial) { + function updateSelectedNode(changes: Partial) { if (!selectedNodeId) { return; } @@ -72,26 +67,38 @@ export function NodeInspector({ if (!selectedNode) { return ( -
+
-

+

No node selected

- Select a service node on the canvas to edit it. + Select a node on the canvas to edit it.

); } return ( -
+
- - Service Node + + {selectedNode.type === "database" ? "Database Node" : "Service Node"}

{selectedNode.data.name}

@@ -113,13 +120,19 @@ export function NodeInspector({ } }} > - - + + Config - + Runtime @@ -127,13 +140,16 @@ export function NodeInspector({
-