Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?


remote/
78 changes: 39 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,67 +1,67 @@
# 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
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.
21 changes: 11 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -21,7 +21,7 @@ function AppContent() {

const graphQuery = useApplicationGraph(selectedAppId);

const [nodes, setNodes, onNodesChange] = useNodesState<ServiceNode>([]);
const [nodes, setNodes, onNodesChange] = useNodesState<GraphNode>([]);

const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);

Expand All @@ -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: {
Expand All @@ -64,22 +65,22 @@ function AppContent() {
}

return (
<div className="relative h-full w-full bg-[#090b0d] text-slate-50">
<div className="relative h-full w-full bg-slate-100 text-slate-950 dark:bg-[#090b0d] dark:text-slate-50">
<TopBar onAddNode={addNode} />

<div className="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_320px] max-[900px]:grid-cols-1">
<div className="relative h-full min-h-0">
<LeftRail />

<main className="relative min-h-0 min-w-0">
<main className="relative h-full min-h-0 w-full min-w-0">
{graphQuery.isPending && (
<div className="grid h-full w-full place-content-center justify-items-center text-center text-slate-400">
<div className="size-8 animate-spin rounded-full border-[3px] border-[#29313d] border-t-indigo-500" />
<div className="grid h-full w-full place-content-center justify-items-center text-center text-slate-500 dark:text-slate-400">
<div className="size-8 animate-spin rounded-full border-[3px] border-slate-300 border-t-indigo-500 dark:border-[#29313d] dark:border-t-indigo-500" />
<p>Loading application graph...</p>
</div>
)}

{graphQuery.isError && (
<div className="grid h-full w-full place-content-center justify-items-center gap-3 text-center text-slate-400">
<div className="grid h-full w-full place-content-center justify-items-center gap-3 text-center text-slate-500 dark:text-slate-400">
<h2 className="text-xl font-semibold text-red-400">
Unable to load graph
</h2>
Expand Down
105 changes: 105 additions & 0 deletions src/components/graph/DatabaseNode.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid gap-1">
<span className="flex items-center gap-1 text-[11px] text-slate-500 dark:text-slate-400">
{Icon && <Icon size={11} />}
{label}
</span>
<strong className="text-xs">{value}</strong>
</div>
);
}

export function DatabaseNode({
data,
selected,
}: NodeProps<DatabaseGraphNode>) {
const status = statusStyles[data.status];

return (
<article
className={`relative w-[340px] rounded-[18px] border bg-emerald-50 p-5 text-slate-950 shadow-[0_14px_35px_rgb(15_23_42/16%)] dark:bg-[#03110c] dark:text-slate-50 dark:shadow-[0_14px_35px_rgb(0_0_0/35%)] max-[560px]:w-[280px] ${
selected
? "border-emerald-500 ring-2 ring-emerald-500/25"
: "border-emerald-200 dark:border-emerald-950"
}`}
>
<Handle
type="target"
position={Position.Left}
className="!size-2.5 !border-2 !border-white !bg-emerald-500 dark:!border-slate-900"
/>

<header className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="grid size-[34px] place-items-center rounded-[9px] bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400">
<Database size={18} />
</span>
<div>
<strong className="block">{data.name}</strong>
<span className="text-[10px] uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
Database
</span>
</div>
</div>

<span className="grid size-[34px] place-items-center rounded-[9px] bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400">
<HardDrive size={17} />
</span>
</header>

<p className="my-4 text-[13px] text-slate-500 dark:text-slate-400">
{data.description}
</p>

<div className="grid grid-cols-3 gap-2">
<Metric label="Capacity" value={`${data.capacity}%`} />
<Metric label="Memory" value="0.05 GB" />
<Metric label="Storage" value="10 GB" icon={HardDrive} />
</div>

<div className="my-[18px] h-[7px] overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-950">
<span
className="block h-full rounded-[inherit] bg-gradient-to-r from-emerald-500 to-teal-400"
style={{ width: `${data.capacity}%` }}
/>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<footer className="flex items-center justify-between">
<span
className={`rounded-md px-2.5 py-1.5 text-xs font-semibold ${status.className}`}
>
{status.label}
</span>
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
Storage
</span>
</footer>

<Handle
type="source"
position={Position.Right}
className="!size-2.5 !border-2 !border-white !bg-emerald-500 dark:!border-slate-900"
/>
</article>
);
}
54 changes: 38 additions & 16 deletions src/components/graph/GraphCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -27,14 +29,6 @@ const defaultEdgeOptions = {
},
};

type GraphCanvasProps = {
nodes: ServiceNode[];
edges: Edge[];
setEdges: Dispatch<SetStateAction<Edge[]>>;
onNodesChange: OnNodesChange<ServiceNode>;
onEdgesChange: OnEdgesChange<Edge>;
};

export function GraphCanvas({
nodes,
edges,
Expand All @@ -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) =>
Expand Down Expand Up @@ -74,14 +91,19 @@ export function GraphCanvas({

return (
<div className="h-full w-full">
<ReactFlow<ServiceNode, Edge>
<ReactFlow<GraphNode, Edge>
nodeTypes={nodeTypes}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={(_, node) => {
setSelectedNodeId(node.id);
setWorkspaceOpen(true);

if (window.matchMedia("(max-width: 900px)").matches) {
setMobilePanelOpen(true);
}
}}
onPaneClick={() => {
setSelectedNodeId(null);
Expand All @@ -97,10 +119,10 @@ export function GraphCanvas({
variant={BackgroundVariant.Dots}
gap={24}
size={1.5}
color="#334155"
color={theme === "dark" ? "#334155" : "#cbd5e1"}
/>

<Controls position="bottom-right" />
<Controls position="bottom-right" orientation="horizontal" />
</ReactFlow>
</div>
);
Expand Down
Loading