Merge master into main

This commit is contained in:
2026-02-20 20:39:36 -08:00
66 changed files with 16596 additions and 1 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules/
dist/
build/
.git/
.gitignore
*.md
.env
.env.*
.vscode/
coverage/
.dockerignore
docker-compose.yml

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
node_modules/
dist/
dist-ssr/
*.local
.env
.env.*
!.env.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

6
.sisyphus/boulder.json Normal file
View File

@@ -0,0 +1,6 @@
{
"active_plan": "/home/christopher/homelab-topology/.sisyphus/plans/remaining-features.md",
"plan_name": "remaining-features",
"started_at": "2026-02-19T07:00:04.537Z",
"session_ids": ["ses_38d041214ffeTbENvsaL3azo8Z"]
}

View File

@@ -0,0 +1,27 @@
# Learnings - Wave 1
## Task 1: Express Backend Foundation
### What worked well
- Used `bun add` to install dependencies - fast and straightforward
- tsx is already available in devDependencies, so no additional setup needed
- CORS configured to allow `http://localhost:3000` (frontend)
- Health endpoint returns expected JSON: `{"status":"ok"}`
### Dependencies added
- express@5.2.1
- cors@2.8.6
- @types/express@5.0.6
- @types/cors@2.8.19
### Files created/modified
- Created: `server/index.ts`
- Modified: `package.json` (added `"server": "tsx server/index.ts"` script)
### Verification
- `curl http://localhost:3001/api/health` returns `{"status":"ok"}`
### Notes
- Server runs on port 3001 to avoid conflict with frontend on port 3000
- Minimal setup - no authentication, no WebSocket support (as per requirements)
- Evidence saved to `.sisyphus/evidence/task-1-health-check.json`

File diff suppressed because it is too large Load Diff

86
AGENTS.md Normal file
View File

@@ -0,0 +1,86 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-02-19
**Commit:** a4cff98
**Branch:** (current)
## OVERVIEW
Homelab topology visualizer - React + Express full-stack app displaying infrastructure as interactive graph (network → hosts → containers → volumes).
## STRUCTURE
```
./
├── server/ # Express API backend
│ └── routes/ # API endpoints (discover, config, stats, files)
├── src/ # React frontend
│ ├── components/ # UI components (Header, LeftPanel, RightPanel, Graph)
│ ├── services/ # Discovery logic
│ ├── store/ # Zustand state
│ ├── types/ # TypeScript definitions
│ └── utils/ # Utilities
├── docker/ # Nginx + Dockerfile
├── dist/ # Built frontend (committed - non-standard)
└── index.html # Frontend entry
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Add API endpoint | server/routes/*.ts | Modular route files |
| Add UI component | src/components/*.tsx | React components |
| State management | src/store/topologyStore.ts | Zustand store |
| Discovery logic | src/services/discovery.ts | Data transformation |
| SSH discovery | src/services/sshDiscovery.ts | SSH connectivity |
## CODE MAP
| Symbol | Type | Location | Role |
|--------|------|----------|------|
| App | component | src/App.tsx | Main orchestrator |
| TopologyGraph | component | src/components/Graph/TopologyGraph.tsx | Graph rendering |
| useTopologyStore | hook | src/store/topologyStore.ts | Global state |
| discoverRouter | router | server/routes/discover.ts | /api/discover |
## CONVENTIONS (THIS PROJECT)
- **TypeScript**: Strict mode enabled, ESNext modules
- **Path alias**: `@/*` maps to `src/*`
- **Frontend**: React 18 + Vite + Tailwind CSS + Zustand
- **Backend**: Express 5 + TypeScript (tsx runtime)
- **Graph**: @xyflow/react (React Flow) + dagre layout
- **Build**: `tsc -b && vite build`
- **Lint**: `eslint .` (flat config)
- **Run dev**: `npm run dev` (frontend), `npm run server` (backend)
## ANTI-PATTERNS (THIS PROJECT)
- **DO NOT commit dist/**: Build artifacts in `dist/` are committed - should be in .gitignore
- **DO NOT skip tsc**: Build script runs `tsc -b` before vite - don't remove
- **DO NOT use localStorage for SSH credentials**: Security risk (documented in .sisyphus/plans)
## UNIQUE STYLES
- Dual runtime: frontend (Vite) + backend (tsx server/index.ts)
- Fallback to simulated data if API unavailable
- Polling-based live updates (configurable interval)
- Graph auto-layout with dagre
## COMMANDS
```bash
npm run dev # Frontend dev server (localhost:3000)
npm run server # Backend API server (localhost:3001)
npm run build # TypeScript + Vite build
npm run lint # ESLint check
npm run discover # Run SSH discovery standalone
```
## NOTES
- Frontend expects API at `http://localhost:3001` (configurable via VITE_API_URL)
- API routes mounted under `/api/*`
- CORS configured for `http://localhost:3000` only
- SSH discovery uses ssh2 library - requires network access to hosts

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:18-alpine
WORKDIR /app
# Non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY package*.json ./
RUN npm ci --only=production
# Copy built frontend
COPY --from=builder /app/dist ./dist
# Copy server files
COPY server/ ./server/
COPY tsconfig*.json ./
ENV NODE_ENV=production
ENV PORT=3001
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
USER appuser
EXPOSE 3000 3001
CMD ["npx", "tsx", "server/index.ts"]

View File

@@ -1,2 +1,76 @@
# homelab-topology # Homelab Topology Visualizer
A responsive, real-time dashboard and visualizer for your homelab infrastructure.
## Features Added via 10-Skill Integration
This project has been thoroughly enhanced with 10 specific infrastructure and development skills:
### 1. Backend & API Security \`api-security-hardening\`, \`nodejs-backend-patterns\`
- Implemented **Helmet** for HTTP response headers.
- Added **Rate Limiting** to prevent abuse on discovery endpoints.
- Structured Error Handling with an object-oriented hierarchy (\`AppError\`) and a global error catching middleware.
### 2. Observability & Logging \`observability-monitoring\`
- Converted from console logs to **Pino** structured logging.
- Includes a custom request logger middleware that outputs formatted JSON in production and readable logs via \`pino-pretty\` in development.
### 3. Real-Time WebSockets \`websocket-engineer\`
- Replaced HTTP polling with **Socket.IO** connections.
- The UI maintains a live connection with the server, receiving \`topology:update\` events exactly when new data is discovered.
- Features resilient reconnection with exponential backoff and connection status indicators in the dashboard footer.
### 4. Containerization \`docker\`
- Full local containerization.
- Features a **Multi-Stage Dockerfile** that builds the Vite frontend and serves via a lightweight Node.js Alpine image.
- A \`docker-compose.yml\` stack makes spinning up both the frontend and backend seamless. Includes a health-check endpoint.
### 5. Testing Infrastructure \`vitest\`
- Integrated **Vitest** for unit testing.
- Includes thorough coverage formatting color utility functions and Zustand store actions. Run with \`npm test\`.
### 6. Dashboards & Visualization \`data-visualizer\`
- Added Recharts-inspired KPI panels (\`MetricsBar.tsx\`).
- Included a pure CSS, screen-reader accessible bar chart mapping services to hosts (\`HostChart.tsx\`).
- Accessible colorblind-safe color loops are enforced.
### 7. Domain Types & Architecture \`infrastructure-monitoring\`, \`proxmox-admin\`, \`network-engineer\`
- **Virtualization:** Native TypeScript support for Proxmox VMs and LXC containers with resource metrics.
- **Networking:** Supported types for VLANs, subnets, and gateways.
- **Monitoring:** API latency and performance are actively measured for network mapping jobs.
## Running Locally
To run this application via Docker Compose:
\`\`\`bash
# Build and start the containers
docker-compose up -d --build
# View logs
docker-compose logs -f
\`\`\`
The applications will be running at:
- **Frontend Dashboard:** http://localhost:3000
- **Backend API:** http://localhost:3001
## Development
\`\`\`bash
# Install dependencies
npm ci
# Start the dev server
npm run dev
\`\`\`
## Testing
\`\`\`bash
# Run unit tests
npm test
# Run tests with coverage
npm run test:coverage
\`\`\`

487
SPEC.md Normal file
View File

@@ -0,0 +1,487 @@
# Homelab Topology Visualizer - Specification
## 1. Project Overview
**Project Name:** Homelab Topology Visualizer (HomelabTV)
**Type:** Interactive Web Application
**Core Functionality:** A visual, interactive graph-based UI that displays the complete topology of a homelab - from network infrastructure (UniFi gateway, VLANs) through hosts, Docker containers/services, to filesystem paths and files.
**Target Users:** Homelab administrators, DevOps engineers, home network enthusiasts
---
## 2. UI/UX Specification
### Layout Structure
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ HEADER: Logo + Title + View Mode Selector + Refresh Button │
├─────────────┬───────────────────────────────────────────────────┬───────────────┤
│ │ │ │
│ LEFT │ MAIN GRAPH CANVAS │ RIGHT │
│ PANEL │ (React Flow / D3.js) │ PANEL │
│ │ │ │
│ Subnodes │ Nodes connected by edges showing │ Details │
│ of │ relationships: │ Tabs: │
│ selected │ Gateway → VLAN → Host → Service → Volume │ │
│ node │ │ - Details │
│ │ [Interactive: click to select, │ - Config │
│ - List │ drag to pan, scroll to zoom] │ - Files │
│ - Click │ │ - Usage │
│ to │ │ - Importance │
│ select │ │ │
│ │ │ │
├─────────────┴───────────────────────────────────────────────────┴───────────────┤
│ FOOTER: Status bar (last refresh, node count, connection status) │
└─────────────────────────────────────────────────────────────────────────────────┘
```
- **Header:** Fixed, 56px height
- **Left Panel:** 280px width, collapsible
- **Right Panel:** 360px width, collapsible
- **Graph Canvas:** Flexible, fills remaining space
- **Footer:** Fixed, 32px height
### Responsive Breakpoints
- **Desktop (≥1200px):** Full 3-column layout
- **Tablet (768px-1199px):** Left panel hidden, right panel 300px
- **Mobile (<768px):** Panels as overlays/drawers
### Visual Design
#### Color Palette
```css
/* Network Layer */
--network-gateway: #6366F1; /* Indigo - UniFi gateway */
--network-vlan: #8B5CF6; /* Purple - VLANs */
--network-wifi: #EC4899; /* Pink - Access points */
/* Host Layer */
--host-physical: #10B981; /* Emerald - Physical servers */
--host-vm: #14B8A6; /* Teal - Virtual machines */
--host-container: #F59E0B; /* Amber - LXC containers */
/* Service Layer */
--service-media: #EF4444; /* Red - Jellyfin, Radarr, etc */
--service-infra: #3B82F6; /* Blue - Traefik, Authentik */
--service-monitoring: #22C55E; /* Green - Prometheus, Grafana */
--service-ai: #F97316; /* Orange - Ollama, Litellm */
--service-storage: #06B6D4; /* Cyan - Storage services */
/* Filesystem Layer */
--filesystem-nfs: #84CC16; /* Lime - NFS mounts */
--filesystem-volume: #A855F7; /* Purple - Docker volumes */
--filesystem-path: #EAB308; /* Yellow - File paths */
/* UI Colors */
--bg-primary: #0F172A; /* Slate 900 */
--bg-secondary: #1E293B; /* Slate 800 */
--bg-tertiary: #334155; /* Slate 700 */
--text-primary: #F8FAFC; /* Slate 50 */
--text-secondary: #94A3B8; /* Slate 400 */
--border: #475569; /* Slate 600 */
--accent: #38BDF8; /* Sky 400 */
--success: #22C55E;
--warning: #F59E0B;
--error: #EF4444;
```
#### Typography
- **Font Family:** `"JetBrains Mono", "Fira Code", monospace` for data; `"Inter", system-ui` for UI
- **Sizes:**
- H1 (Title): 24px, weight 700
- H2 (Section): 18px, weight 600
- H3 (Node name): 14px, weight 600
- Body: 13px, weight 400
- Caption: 11px, weight 400
#### Spacing System
- Base unit: 4px
- Padding: 8px (sm), 12px (md), 16px (lg), 24px (xl)
- Margins: 4px (xs), 8px (sm), 16px (md), 24px (lg)
- Border radius: 4px (sm), 8px (md), 12px (lg)
#### Visual Effects
- **Node shadows:** `0 2px 8px rgba(0,0,0,0.3)`
- **Panel shadows:** `0 4px 24px rgba(0,0,0,0.4)`
- **Glow on hover:** `0 0 12px var(--node-color)`
- **Transitions:** 200ms ease-out for all interactive elements
- **Graph edges:** Animated dashed lines for active connections
### Components
#### Node Component
- **Shape:** Rounded rectangle (120x48px) or circle (48x48px) for endpoints
- **Content:** Icon + Label + Status indicator
- **States:**
- Default: bg-secondary, border solid
- Hover: Glow effect, scale 1.02
- Selected: Border 2px accent, elevated shadow
- Running: Green status dot
- Stopped: Red status dot
- Warning: Yellow status dot
#### Edge Component
- **Style:** Curved bezier lines with arrows
- **States:**
- Default: border 1px text-secondary
- Active/Selected: border 2px accent, animated dash
#### Left Panel - Subnodes
- **Header:** "CHILD NODES" with count badge
- **List Items:** Icon + Name + Type badge
- **Interaction:** Click to focus/select in graph
- **Empty state:** "No child nodes"
#### Right Panel - Details Tabs
- **Tab Bar:** Details | Config | Files | Usage | Importance
- **Details Tab:** IP, MAC, Status, Uptime, Type, Description
- **Config Tab:** YAML/JSON view of configuration
- **Files Tab:** Tree view of relevant file paths
- **Usage Tab:** CPU, Memory, Network graphs
- **Importance Tab:** Critical / High / Medium / Low with reasoning
#### View Mode Selector
- **Options:**
- 🌐 Network: Shows gateway, VLANs, WiFi, hosts
- 🖥️ Hosts: Shows hosts and their services
- 📦 Services: Shows all services and their dependencies
- 💾 Filesystem: Shows volumes, mounts, paths
- 🔗 Full: Complete topology (default)
---
## 3. Functionality Specification
### Core Features
#### F1: Interactive Graph Visualization
- Render infrastructure as directed graph
- Pan, zoom, fit-to-view controls
- Click node to select (shows details)
- Double-click to focus/center
- Drag nodes to rearrange
- Minimap for navigation
#### F2: Node Selection & Navigation
- Single click: Select node, show details in right panel
- Double click: Zoom to node, expand children
- Right click: Context menu (copy IP, open URL, etc.)
- Keyboard: Arrow keys to navigate, Enter to select
#### F3: Multi-View Modes
- **Network View:** UniFi → VLANs → Subnets → Hosts
- **Host View:** Hosts → VMs/LXCs → Containers
- **Service View:** Services → Dependencies → Volumes
- **Filesystem View:** TrueNAS → NFS → Volumes → Paths
- **Full View:** Everything connected
#### F4: Detail Panels
**Left Panel (Subnodes):**
- Shows children of selected node
- Click to select in graph
- Shows node count per type
**Right Panel (Details):**
- 5 tabs with content
- Details: Basic info (IP, type, status)
- Config: Docker compose, YAML configs
- Files: Related file paths (clickable)
- Usage: Resource consumption
- Importance: 1-5 stars with why
#### F5: Data Discovery (Hybrid Approach)
**Static Config (JSON/YAML):**
```json
{
"network": {
"gateway": { "model": "UniFi Dream Machine Pro", "ip": "192.168.1.1" },
"vlans": [
{ "id": 10, "name": "Family", "subnet": "192.168.10.0/24" },
{ "id": 30, "name": "IoT", "subnet": "192.168.30.0/24" },
{ "id": 50, "name": "Production", "subnet": "192.168.50.0/24" }
],
"wifi": [
{ "ssid": "Will of D.", "vlan": "default" },
{ "ssid": "Will of D. IoT", "vlan": 30 }
]
},
"hosts": [
{
"name": "ubuntu",
"ip": "192.168.50.61",
"type": "vm",
"role": "Primary Docker Host",
"containers": ["traefik", "jellyfin", "authentik", ...]
},
{
"name": "grizzley",
"ip": "192.168.50.84",
"type": "rpi5",
"role": "Edge Services",
"containers": ["frigate", "traefik"]
}
]
}
```
**Auto-Discovery (SSH):**
- SSH to hosts and run commands
- `docker ps --format json` for containers
- `docker network inspect` for network topology
- `ls /mnt/truenas/` for NFS mounts
- `cat /proc/cpuinfo` for hardware info
#### F6: Search & Filter
- Search bar: Filter nodes by name
- Type filter: Show/hide node types
- Status filter: Running/Stopped/All
- Quick jump: Cmd+K to open command palette
#### F7: Real-time Updates
- Poll intervals: 30s (configurable)
- WebSocket option for live updates
- Visual indicator for stale data
### User Interactions & Flows
1. **Initial Load:**
- Load static config
- Run auto-discovery in background
- Render graph with all nodes
- Fit to view
2. **Select Node:**
- Click node → Highlight path to root
- Left panel shows children
- Right panel shows details (Details tab)
3. **Change View:**
- Click view mode → Re-render graph with filter
- Preserve selected node if visible
4. **View Service Config:**
- Select service node
- Click "Config" tab
- Shows docker-compose.yml content
- Clickable file paths
5. **Refresh Data:**
- Click refresh → Show loading state
- Run discovery commands
- Update graph
- Show "Last updated: X seconds ago"
### Data Handling
- **State Management:** React Context + useReducer
- **Persistence:** LocalStorage for user preferences (view mode, panel states)
- **Caching:** In-memory cache with 30s TTL
- **Error Handling:** Show stale data with warning banner if discovery fails
### Edge Cases
- Host unreachable: Show node with "Offline" status, gray out
- Container stopped: Show with red indicator, allow config view
- Empty discovery: Use static config as fallback
- Large graphs (100+ nodes): Enable clustering, lazy render
- Mobile: Simplified single-panel view with drawer navigation
---
## 4. Technical Architecture
### Tech Stack
- **Framework:** React 18 + TypeScript
- **Build Tool:** Vite
- **Graph Library:** React Flow (@xyflow/react)
- **Styling:** Tailwind CSS
- **State:** Zustand (lightweight)
- **HTTP Client:** Axios
- **SSH:** Node-SSH (backend) or jsch (frontend via proxy)
- **Icons:** Lucide React
- **Charts:** Recharts (for usage graphs)
### Project Structure
```
homelab-topology/
├── src/
│ ├── components/
│ │ ├── Graph/
│ │ │ ├── TopologyGraph.tsx
│ │ │ ├── NodeComponent.tsx
│ │ │ ├── EdgeComponent.tsx
│ │ │ └── controls.tsx
│ │ ├── Panels/
│ │ │ ├── LeftPanel.tsx
│ │ │ ├── RightPanel.tsx
│ │ │ └── DetailTabs/
│ │ ├── Header/
│ │ │ └── Header.tsx
│ │ └── common/
│ │ ├── Button.tsx
│ │ ├── Badge.tsx
│ │ └── Tabs.tsx
│ ├── hooks/
│ │ ├── useTopology.ts
│ │ ├── useDiscovery.ts
│ │ └── useSelection.ts
│ ├── store/
│ │ └── topologyStore.ts
│ ├── types/
│ │ └── index.ts
│ ├── utils/
│ │ ├── graphLayout.ts
│ │ └── formatters.ts
│ ├── data/
│ │ └── staticConfig.ts
│ ├── services/
│ │ └── discovery.ts
│ ├── App.tsx
│ ├── main.tsx
│ └── index.css
├── public/
├── docker/
│ └── Dockerfile
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
└── README.md
```
### Backend/API (Optional)
If running auto-discovery, need a small backend:
- **Technology:** Node.js + Express
- **Purpose:** Proxy SSH commands to homelab hosts
- **Endpoints:**
- `GET /api/discover/:host` - Run discovery on specific host
- `GET /api/containers` - Get all containers across hosts
- `GET /api/volumes` - Get all Docker volumes
---
## 5. Acceptance Criteria
### Visual Checkpoints
- [ ] Graph renders with nodes and edges
- [ ] Nodes are color-coded by type
- [ ] Clicking a node highlights it and shows details
- [ ] Left panel shows child nodes of selected
- [ ] Right panel shows 5 tabs with content
- [ ] View mode selector changes graph content
- [ ] Panels can be collapsed/expanded
- [ ] Search filters nodes in real-time
### Functional Checkpoints
- [ ] Initial data loads from static config
- [ ] Auto-discovery populates missing data
- [ ] Graph is pannable and zoomable
- [ ] Node selection is persistent across view changes
- [ ] Config tab shows Docker compose content
- [ ] Files tab shows relevant file paths
- [ ] Refresh button updates data
### Performance Checkpoints
- [ ] Initial load < 2 seconds
- [ ] Graph renders 100+ nodes smoothly
- [ ] Interactions feel responsive (< 100ms)
### Deployment Checkpoints
- [ ] Builds successfully with `npm run build`
- [ ] Docker image builds and runs
- [ ] Accessible at localhost:3000
- [ ] Works in browser via local network
---
## 6. Data Model
### Node Types
```typescript
type NodeType =
| 'gateway'
| 'vlan'
| 'wifi'
| 'host_physical'
| 'host_vm'
| 'host_container'
| 'service'
| 'volume'
| 'mount'
| 'path';
interface TopologyNode {
id: string;
type: NodeType;
name: string;
data: {
ip?: string;
mac?: string;
status: 'running' | 'stopped' | 'unknown';
metadata: Record<string, any>;
config?: string;
files?: string[];
importance: 1 | 2 | 3 | 4 | 5;
};
position?: { x: number; y: number };
}
interface TopologyEdge {
id: string;
source: string;
target: string;
type?: string;
label?: string;
}
```
---
## 7. Implementation Priority
### Phase 1 (MVP - Week 1)
1. Set up React + React Flow project
2. Create static config with hardcoded topology
3. Render basic graph with nodes and edges
4. Implement node selection
5. Build right panel with Details tab
6. Add view mode selector
### Phase 2 (Week 2)
1. Complete right panel tabs (Config, Files, Usage, Importance)
2. Build left panel with child nodes
3. Add search and filters
4. Implement basic auto-discovery (Docker)
### Phase 3 (Week 3)
1. Add SSH-based discovery
2. Implement real-time updates
3. Add animations and polish
4. Create Docker build
### Phase 4 (Ongoing)
1. Add more node types
2. Improve auto-discovery coverage
3. Add mobile optimizations
4. Add saved layouts

941
bun.lock Normal file
View File

@@ -0,0 +1,941 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "homelab-topology",
"dependencies": {
"@types/cors": "^2.8.19",
"@types/dagre": "^0.7.53",
"@types/express": "^5.0.6",
"@xyflow/react": "^12.4.4",
"cors": "^2.8.6",
"dagre": "^0.8.5",
"express": "^5.2.1",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.0",
"zustand": "^5.0.2",
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/node": "^22.0.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/ssh2": "^1.15.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/dagre": ["@types/dagre@0.7.53", "", {}, "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.0", "@typescript-eslint/types": "^8.56.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0" } }, "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/utils": "8.56.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.56.0", "", {}, "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.0", "@typescript-eslint/tsconfig-utils": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@xyflow/react": ["@xyflow/react@12.10.0", "", { "dependencies": { "@xyflow/system": "0.0.74", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw=="],
"@xyflow/system": ["@xyflow/system@0.0.74", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"autoprefixer": ["autoprefixer@10.4.24", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": "bin/autoprefixer" }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": "dist/cli.js" }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"dagre": ["dagre@0.8.5", "", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": "bin/esbuild" }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graphlib": ["graphlib@2.1.8", "", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@1.21.7", "", { "bin": "bin/jiti.js" }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.468.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"typescript-eslint": ["typescript-eslint@8.56.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.0", "@typescript-eslint/parser": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/utils": "8.56.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["immer"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.0", "", {}, "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q=="],
"@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["immer"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"tinyglobby/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
}
}

20
dist/index.html vendored Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Homelab Topology Visualizer</title>
<meta name="description" content="Interactive graph-based visualizer for homelab network topology — hosts, containers, services, and storage." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BsyZf1s0.js"></script>
<link rel="modulepreload" crossorigin href="/assets/graph-vendor-C44rQwKI.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DpLh-vKM.js">
<link rel="stylesheet" crossorigin href="/assets/index-soHg8pn4.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

48
docker-compose.yml Normal file
View File

@@ -0,0 +1,48 @@
version: '3.8'
services:
# Frontend dev server (Vite HMR)
frontend:
build:
context: .
dockerfile: Dockerfile
target: builder
ports:
- "3000:3000"
volumes:
- ./src:/app/src
- ./index.html:/app/index.html
- /app/node_modules
command: npm run dev -- --host 0.0.0.0
environment:
- VITE_API_URL=http://backend:3001
depends_on:
backend:
condition: service_healthy
# Backend API + WebSocket server
backend:
build:
context: .
dockerfile: Dockerfile
target: builder
ports:
- "3001:3001"
volumes:
- ./server:/app/server
- /app/node_modules
command: npx tsx --watch server/index.ts
environment:
- NODE_ENV=development
- CORS_ORIGIN=http://localhost:3000
- LOG_LEVEL=debug
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
default:
name: homelab-topology

17
docker/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

24
docker/nginx.conf Normal file
View File

@@ -0,0 +1,24 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Homelab Topology Visualizer</title>
<meta name="description" content="Interactive graph-based visualizer for homelab network topology — hosts, containers, services, and storage." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8013
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "homelab-topology",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"discover": "npx tsx src/services/sshDiscovery.ts",
"server": "tsx server/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@types/cors": "^2.8.19",
"@types/dagre": "^0.7.53",
"@types/express": "^5.0.6",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.4.4",
"cors": "^2.8.6",
"dagre": "^0.8.5",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"lucide-react": "^0.468.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"ssh2": "^1.17.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/express-rate-limit": "^5.1.3",
"@types/jsdom": "^27.0.0",
"@types/node": "^22.0.0",
"@types/pino": "^7.0.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/ssh2": "^1.15.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jsdom": "^28.1.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5",
"vitest": "^4.0.18"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

60
server/AGENTS.md Normal file
View File

@@ -0,0 +1,60 @@
# Server (Express API Backend)
**Generated:** 2026-02-19
**Location:** server/
## OVERVIEW
Express 5 + TypeScript backend serving REST API endpoints for homelab topology discovery.
## STRUCTURE
```
server/
├── index.ts # Express app entry, CORS, route mounting
├── config.ts # Server configuration
├── types.ts # Shared TypeScript types
├── config.json # Runtime config (contains hosts, credentials)
├── config.example.json
└── routes/ # API endpoints
├── discover.ts # POST /api/discover - SSH discovery
├── config.ts # GET/PUT /api/config
├── stats.ts # GET /api/stats
└── files.ts # GET /api/files
```
## WHERE TO LOOK
| Task | File | Notes |
|------|------|-------|
| Add new endpoint | server/routes/{name}.ts | Create router, import in index.ts |
| Modify CORS | server/index.ts | CORS middleware (line 12-15) |
| Change port | server/index.ts | PORT const (line 9) |
## CONVENTIONS
- **Route files**: Export default router, mount in index.ts via `app.use('/api', router)`
- **Error handling**: Return JSON `{ error: string }` on failure
- **Config**: Use server/config.ts for shared config, not hardcode
- **Credentials**: Never log or expose SSH credentials in responses
## ANTI-PATTERNS
- **NEVER expose SSH credentials in API responses**
- **DO NOT store credentials in source** - use server/config.json
## API ENDPOINTS
| Method | Path | Purpose |
|--------|------|---------|
| POST | /api/discover | Run SSH discovery on hosts |
| GET | /api/config | Get configuration |
| PUT | /api/config | Update configuration |
| GET | /api/stats | Get statistics |
| GET | /api/files | Get file topology |
## NOTES
- Server runs on port 3001
- CORS allows only `http://localhost:3000`
- Health check: GET /api/health

View File

@@ -0,0 +1,23 @@
{
"hosts": [
{
"name": "ubuntu",
"ip": "192.168.50.61",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22
},
{
"name": "grizzley",
"ip": "192.168.50.84",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519"
},
{
"name": "truenas",
"ip": "192.168.50.12",
"sshUser": "root",
"sshKeyPath": "~/.ssh/id_ed25519"
}
]
}

76
server/config.ts Normal file
View File

@@ -0,0 +1,76 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
import { HostConfig } from './types';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG_FILE = path.join(__dirname, 'hosts.json');
function parseEnvHosts(): HostConfig[] {
const hostsEnv = process.env.SSH_HOSTS;
if (!hostsEnv) return [];
const hosts: HostConfig[] = [];
const entries = hostsEnv.split(',').map(h => h.trim()).filter(Boolean);
for (const entry of entries) {
const [name, ip] = entry.split(':');
if (name && ip) {
hosts.push({
name: name.trim(),
ip: ip.trim(),
sshUser: process.env.SSH_USER || 'bear',
sshKeyPath: process.env.SSH_KEY,
sshPort: process.env.SSH_PORT ? parseInt(process.env.SSH_PORT, 10) : 22,
});
}
}
return hosts;
}
function parseJsonConfig(): HostConfig[] {
if (!fs.existsSync(CONFIG_FILE)) {
console.error('Config file not found:', CONFIG_FILE);
return [];
}
try {
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
const data = JSON.parse(content);
if (!data.hosts || !Array.isArray(data.hosts)) {
console.error('No hosts array in config');
return [];
}
const hosts = data.hosts.map((h: Partial<HostConfig>) => ({
name: h.name || '',
ip: h.ip || '',
sshUser: h.sshUser || 'bear',
sshKeyPath: h.sshKeyPath?.replace(/^~/, homedir()),
sshPort: h.sshPort || 22,
})).filter((h: HostConfig) => h.name && h.ip);
console.error('Loaded hosts:', JSON.stringify(hosts));
return hosts;
} catch (e: any) {
console.error('Config parse error:', e.message);
return [];
}
}
export function getHostConfigs(): HostConfig[] {
const envHosts = parseEnvHosts();
if (envHosts.length > 0) {
return envHosts;
}
return parseJsonConfig();
}
export function hasConfig(): boolean {
return getHostConfigs().length > 0;
}

36
server/errors.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* Custom Error Classes for Homelab Topology API
*
* Structured error hierarchy following nodejs-backend-patterns skill.
* All operational errors extend AppError with appropriate HTTP status codes.
*/
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public isOperational: boolean = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(message: string = 'Resource not found') {
super(message, 404);
}
}
export class ValidationError extends AppError {
constructor(message: string, public errors?: Array<{ field: string; message: string }>) {
super(message, 400);
}
}
export class ServiceUnavailableError extends AppError {
constructor(message: string = 'Service temporarily unavailable') {
super(message, 503);
}
}

52
server/hosts.json Normal file
View File

@@ -0,0 +1,52 @@
{
"hosts": [
{
"name": "ubuntu",
"ip": "192.168.50.61",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "docker-host"
},
{
"name": "grizzley",
"ip": "192.168.50.84",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "docker-host"
},
{
"name": "truenas",
"ip": "192.168.50.12",
"sshUser": "root",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "truenas"
},
{
"name": "proxmox",
"ip": "192.168.50.11",
"sshUser": "root",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "proxmox"
},
{
"name": "ice",
"ip": "192.168.50.197",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "docker-host"
},
{
"name": "panda",
"ip": "192.168.50.196",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "docker-host"
}
]
}

107
server/index.ts Normal file
View File

@@ -0,0 +1,107 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { createServer } from 'http';
import { Server, Socket } from 'socket.io';
import discoverRouter from './routes/discover';
import configRouter from './routes/config';
import statsRouter from './routes/stats';
import filesRouter from './routes/files';
import terminalRouter from './routes/terminal';
import { getHostConfigs } from './config';
import { requestLogger, logger } from './middleware/requestLogger';
import { errorHandler } from './middleware/errorHandler';
const app = express();
const httpServer = createServer(app);
const PORT = 3001;
// --- Socket.IO setup (websocket-engineer skill) ---
const io = new Server(httpServer, {
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
},
pingInterval: 25000,
pingTimeout: 10000,
});
io.on('connection', (socket: Socket) => {
logger.info({ socketId: socket.id }, 'Client connected via WebSocket');
socket.on('disconnect', (reason: string) => {
logger.info({ socketId: socket.id, reason }, 'Client disconnected');
});
});
// Export io so routes can emit events
export { io };
// --- Security middleware (api-security-hardening skill) ---
app.use(helmet({
contentSecurityPolicy: false, // Disable CSP for dev — configure per-environment in production
}));
// CORS — restrict to configured origins
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
}));
// Rate limiting — general API
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
message: { status: 'error', message: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false,
}));
// Stricter rate limiting for discovery (expensive operation)
app.use('/api/discover', rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 10,
message: { status: 'error', message: 'Discovery rate limited — max 10 per minute' },
standardHeaders: true,
legacyHeaders: false,
}));
// --- Body parsing ---
app.use(express.json({ limit: '1mb' }));
// --- Observability (observability-monitoring skill) ---
app.use(requestLogger);
// --- Health check ---
app.get('/api/health', (_req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
connections: io.engine.clientsCount,
});
});
// --- Debug endpoint (dev only) ---
if (process.env.NODE_ENV !== 'production') {
app.get('/api/debug-config', (_req, res) => {
const hosts = getHostConfigs();
res.json({ hosts });
});
}
// --- Routes ---
app.use('/api', discoverRouter);
app.use('/api', configRouter);
app.use('/api', statsRouter);
app.use('/api', filesRouter);
app.use('/api', terminalRouter);
// --- Global error handler (must be last) ---
app.use(errorHandler);
// --- Start server ---
httpServer.listen(PORT, () => {
logger.info(`Server running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,42 @@
import { Request, Response, NextFunction } from 'express';
import { AppError, ValidationError } from '../errors';
import { logger } from './requestLogger';
/**
* Global error handler middleware.
* Catches all errors, logs unexpected ones with Pino, and returns safe JSON responses.
* Must be registered AFTER all routes.
*/
export const errorHandler = (
err: Error,
req: Request,
res: Response,
_next: NextFunction
) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message,
...(err instanceof ValidationError && err.errors ? { errors: err.errors } : {}),
});
}
// Log unexpected errors
logger.error({
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
});
// Don't leak error details in production
const message =
process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message;
res.status(500).json({
status: 'error',
message,
});
};

View File

@@ -0,0 +1,42 @@
import { Request, Response, NextFunction } from 'express';
import pino from 'pino';
/**
* Pino structured logger.
* - Development: colorized, human-readable output
* - Production: JSON for log aggregation
*/
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
...(process.env.NODE_ENV !== 'production' && {
transport: {
target: 'pino-pretty',
options: { colorize: true },
},
}),
});
/**
* Request logging middleware.
* Logs method, url, status, and duration for every request.
*/
export const requestLogger = (
req: Request,
res: Response,
next: NextFunction
) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info({
method: req.method,
url: req.url,
status: res.statusCode,
duration: `${duration}ms`,
ip: req.ip,
});
});
next();
};

49
server/routes/AGENTS.md Normal file
View File

@@ -0,0 +1,49 @@
# Server Routes (API Endpoints)
**Generated:** 2026-02-19
**Location:** server/routes/
## OVERVIEW
Express router modules exposing REST API endpoints for the homelab topology.
## ENDPOINTS
| File | Route | Method | Purpose |
|------|-------|--------|---------|
| discover.ts | /api/discover | POST | Run SSH discovery on specified hosts |
| config.ts | /api/config | GET/PUT | Get or update configuration |
| stats.ts | /api/stats | GET | Retrieve statistics |
| files.ts | /api/files | GET | Get file topology |
## ADDING A NEW ENDPOINT
1. Create `server/routes/{name}.ts`:
```typescript
import { Router } from 'express';
const router = Router();
router.get('/{name}', (req, res) => {
// implementation
});
export default router;
```
2. Import and mount in `server/index.ts`:
```typescript
import newRouter from './routes/{name}';
app.use('/api', newRouter);
```
## CONVENTIONS
- All routes prefixed with `/api` (mounted in index.ts)
- Return JSON on success: `{ data: ... }`
- On error: `{ error: string }`
- Use async/await for async operations
## NOTES
- discover.ts: Main endpoint - accepts host list, returns topology data
- CORS is configured at server/index.ts level, not per-route

215
server/routes/config.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* SSH Config API Endpoint
*
* GET /api/config/:host/:container - Get docker-compose config for a specific container
*/
import { Router } from 'express';
import { Client } from 'ssh2';
import { readFileSync } from 'fs';
import { homedir } from 'os';
import { getHostConfigs } from '../config';
import { ConfigResponse } from '../types';
const router = Router();
interface SSHConnectionConfig {
host: string;
port?: number;
username: string;
privateKey?: Buffer;
}
async function connectSSH(config: SSHConnectionConfig, timeout: number = 30000): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timeoutId = setTimeout(() => {
conn.end();
reject(new Error('Connection timeout'));
}, timeout);
conn.on('ready', () => {
clearTimeout(timeoutId);
resolve(conn);
});
conn.on('error', (err) => {
clearTimeout(timeoutId);
reject(err);
});
conn.connect({
host: config.host,
port: config.port || 22,
username: config.username,
privateKey: config.privateKey,
readyTimeout: timeout,
});
});
}
async function execSSH(conn: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
let output = '';
stream.on('close', () => resolve(output));
stream.on('data', (data: Buffer) => { output += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
});
});
}
// Find docker-compose.yml files on the remote host
async function findDockerComposeFiles(conn: Client): Promise<string[]> {
const command = `find /home -name "docker-compose.yml" 2>/dev/null | head -10`;
const output = await execSSH(conn, command);
const files = output
.split('\n')
.map(f => f.trim())
.filter(f => f.length > 0);
// Also check common locations
const commonPaths = [
`${homedir()}/docker-compose.yml`,
'/opt/docker-compose.yml',
'/root/docker-compose.yml'
];
return [...new Set([...files, ...commonPaths])];
}
// Extract a specific service from docker-compose.yml
function extractServiceFromYaml(fullYaml: string, serviceName: string): string {
const lines = fullYaml.split('\n');
let inService = false;
let serviceLines: string[] = [];
let indentLevel = 0;
for (const line of lines) {
// Check if we're starting the target service
if (line.match(new RegExp(`^\\s+${serviceName}:`)) || line.match(new RegExp(`^${serviceName}:`))) {
inService = true;
indentLevel = line.match(/^(\s*)/)![1].length;
serviceLines.push(line);
continue;
}
if (inService) {
const currentIndent = line.match(/^(\s*)/)![1].length;
// If we've dedented back to or past the service level, we're done
if (line.trim() && currentIndent <= indentLevel) {
// Check if this is another top-level key (services, volumes, networks)
if (line.match(/^(services|volumes|networks):/)) {
break;
}
// If this is another service, we're done
if (!line.startsWith(' ')) {
break;
}
}
serviceLines.push(line);
}
}
if (serviceLines.length === 0) {
return `# Service "${serviceName}" not found in docker-compose.yml`;
}
return serviceLines.join('\n');
}
// Read docker-compose.yml from remote host and extract service
async function getContainerConfig(conn: Client, containerName: string): Promise<ConfigResponse> {
const files = await findDockerComposeFiles(conn);
for (const filePath of files) {
try {
const command = `cat "${filePath}"`;
const content = await execSSH(conn, command);
if (content && content.includes(containerName)) {
const serviceConfig = extractServiceFromYaml(content, containerName);
return {
yaml: serviceConfig,
path: filePath
};
}
} catch (err) {
console.error(`Error reading ${filePath}:`, err);
continue;
}
}
return {
yaml: '',
path: '',
error: `No docker-compose.yml found with service "${containerName}"`
};
}
// GET /api/config/:host/:container
router.get('/config/:host/:container', async (req, res) => {
const { host, container } = req.params;
console.log(`Fetching config for ${container} on ${host}`);
try {
// Find host config
const hostConfigs = getHostConfigs();
const hostConfig = hostConfigs.find(h => h.name === host);
if (!hostConfig) {
return res.status(404).json({
yaml: '',
path: '',
error: `Host "${host}" not found in configuration`
});
}
// Read SSH key
let privateKey: Buffer | undefined;
if (hostConfig.sshKeyPath) {
const keyPath = hostConfig.sshKeyPath.replace(/^~/, homedir());
try {
privateKey = readFileSync(keyPath);
} catch (err) {
return res.status(500).json({
yaml: '',
path: '',
error: `Failed to read SSH key: ${err}`
});
}
}
// Connect to host via SSH
const conn = await connectSSH({
host: hostConfig.ip,
port: hostConfig.sshPort || 22,
username: hostConfig.sshUser,
privateKey
}, 30000);
// Get container config
const config = await getContainerConfig(conn, container);
conn.end();
res.json(config);
} catch (err: any) {
console.error('Error fetching config:', err);
res.status(500).json({
yaml: '',
path: '',
error: err.message || 'Failed to fetch config'
});
}
});
export default router;

169
server/routes/discover.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* SSH Discovery API Endpoint
*
* POST /api/discover - Discover all hosts via SSH and return their status
*/
import { Router } from 'express';
import { execSync } from 'child_process';
import { homedir } from 'os';
import { getHostConfigs } from '../config';
import { DiscoveryResponse } from '../types';
const router = Router();
interface HostDiscoveryResult {
name: string;
ip: string;
online: boolean;
containers?: string[];
services?: string[];
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
error?: string;
}
async function discoverHost(
name: string,
ip: string,
sshUser: string,
sshKeyPath?: string,
sshPort?: number,
hostType?: string
): Promise<HostDiscoveryResult> {
try {
const keyPath = (sshKeyPath || `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
console.error(`DEBUG: ${name} keyPath=${keyPath}, user=${sshUser}`);
const keyArg = `-i ${keyPath}`;
const portArg = sshPort && sshPort !== 22 ? `-p ${sshPort}` : '';
const dockerCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "docker ps --format '{{.Names}}'" 2>/dev/null`;
const dockerOutput = execSync(dockerCmd, { encoding: 'utf-8', timeout: 15000 });
const containers = dockerOutput.trim().split('\n').filter(c => c.trim());
let services: string[] = [];
if (hostType !== 'proxmox') {
try {
const systemdCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "systemctl list-units --type=service --state=running --no-pager --no-legend --quiet | awk '{print \\$1}'" 2>/dev/null`;
const systemdOutput = execSync(systemdCmd, { encoding: 'utf-8', timeout: 10000 });
services = systemdOutput.trim().split('\n').filter(s => s.trim());
} catch {
console.error(`DEBUG: ${name} systemd discovery failed`);
}
}
let vms: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }> = [];
if (hostType === 'proxmox' || name === 'proxmox') {
try {
const lxcCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "pct list" 2>/dev/null`;
const lxcOutput = execSync(lxcCmd, { encoding: 'utf-8', timeout: 10000 });
const lxcLines = lxcOutput.trim().split('\n').slice(1);
for (const line of lxcLines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2) {
vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'lxc' });
}
}
const vmCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "qm list" 2>/dev/null`;
const vmOutput = execSync(vmCmd, { encoding: 'utf-8', timeout: 10000 });
const vmLines = vmOutput.trim().split('\n').slice(1);
for (const line of vmLines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2) {
vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'qemu' });
}
}
} catch {
console.error(`DEBUG: ${name} Proxmox discovery failed`);
}
}
return {
name,
ip,
online: true,
containers,
services: services.length > 0 ? services : undefined,
vms: vms.length > 0 ? vms : undefined,
};
} catch (error: any) {
return {
name,
ip,
online: false,
containers: [],
error: error.message || 'Discovery failed',
};
}
}
// POST /api/discover - Discover all hosts via SSH
router.post('/discover', async (req, res) => {
try {
const hosts = getHostConfigs();
if (hosts.length === 0) {
const response: DiscoveryResponse = {
hosts: [],
timestamp: new Date().toISOString(),
errors: ['No hosts configured'],
};
return res.json(response);
}
const results: HostDiscoveryResult[] = [];
for (const host of hosts) {
try {
const keyPath = host.sshKeyPath?.replace(/^~/, homedir());
const result = await discoverHost(
host.name,
host.ip,
host.sshUser,
keyPath,
host.sshPort,
host.hostType
);
results.push(result);
} catch (error: any) {
results.push({
name: host.name,
ip: host.ip,
online: false,
containers: [],
error: error.message || 'Discovery failed'
});
}
}
const errors: string[] = [];
results.forEach((result: HostDiscoveryResult) => {
if (!result.online && result.error) {
errors.push(`${result.name}: ${result.error}`);
}
});
const response: DiscoveryResponse = {
hosts: results.map(r => ({
name: r.name,
ip: r.ip,
online: r.online,
containers: r.containers,
services: r.services,
vms: r.vms,
})),
timestamp: new Date().toISOString(),
errors,
};
res.json(response);
} catch (error: any) {
const response: DiscoveryResponse = {
hosts: [],
timestamp: new Date().toISOString(),
errors: [error.message || 'Discovery failed'],
};
res.status(500).json(response);
}
});
export default router;

265
server/routes/files.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* Files API Endpoint
*
* GET /api/files/:host/:container - Get volume mounts for a container
*/
import { Router } from 'express';
import { Client } from 'ssh2';
import { readFileSync } from 'fs';
import { homedir } from 'os';
import { getHostConfigs } from '../config';
const router = Router();
interface SSHConnectionConfig {
host: string;
port?: number;
username: string;
privateKey?: Buffer;
}
interface VolumeMount {
source: string;
destination: string;
mode: string;
}
interface FilesResponse {
volumes: VolumeMount[];
error?: string;
}
async function connectSSH(config: SSHConnectionConfig, timeout: number = 30000): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timeoutId = setTimeout(() => {
conn.end();
reject(new Error('Connection timeout'));
}, timeout);
conn.on('ready', () => {
clearTimeout(timeoutId);
resolve(conn);
});
conn.on('error', (err) => {
clearTimeout(timeoutId);
reject(err);
});
conn.connect({
host: config.host,
port: config.port || 22,
username: config.username,
privateKey: config.privateKey,
readyTimeout: timeout,
});
});
}
async function execSSH(conn: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
let output = '';
stream.on('close', () => resolve(output));
stream.on('data', (data: Buffer) => { output += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
});
});
}
async function getContainerVolumes(
ip: string,
containerName: string,
sshUser: string,
sshKeyPath?: string,
sshPort?: number
): Promise<FilesResponse> {
try {
// Load SSH key
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
let privateKey: Buffer | undefined;
try {
privateKey = readFileSync(keyPath);
} catch {
// If key file doesn't exist, try without it
}
const sshConfig: SSHConnectionConfig = {
host: ip,
port: sshPort || 22,
username: sshUser,
privateKey,
};
const conn = await connectSSH(sshConfig, 30000);
// Run docker inspect to get mounts
const command = `docker inspect ${containerName} --format '{{json .Mounts}}'`;
const output = await execSSH(conn, command);
conn.end();
// Parse JSON output
let mounts: any[] = [];
try {
mounts = JSON.parse(output.trim()) || [];
} catch {
// If parsing fails, return empty array
return { volumes: [] };
}
// Transform to VolumeMount format
const volumes: VolumeMount[] = mounts.map((mount: any) => ({
source: mount.Source || mount.Source || '',
destination: mount.Destination || mount.Destination || '',
mode: mount.Mode || mount.Mode || 'rw',
})).filter((v: VolumeMount) => v.source && v.destination);
return { volumes };
} catch (error: any) {
return {
volumes: [],
error: error.message || 'Failed to get container volumes',
};
}
}
// GET /api/files/:host/:container - Get volume mounts for a container
router.get('/files/:host/:container', async (req, res) => {
try {
const { host, container } = req.params;
const hosts = getHostConfigs();
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
if (!hostConfig) {
const response: FilesResponse = {
volumes: [],
error: `Host '${host}' not found in configuration`,
};
return res.status(404).json(response);
}
const result = await getContainerVolumes(
hostConfig.ip,
container,
hostConfig.sshUser,
hostConfig.sshKeyPath,
hostConfig.sshPort
);
res.json(result);
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : 'Failed to get container volumes';
const response: FilesResponse = {
volumes: [],
error: errMsg,
};
res.status(500).json(response);
}
});
interface FileEntry {
name: string;
path: string;
type: 'file' | 'directory' | 'symlink';
size: number;
modified: string;
}
interface BrowseResponse {
path: string;
files: FileEntry[];
error?: string;
}
async function browseDirectory(
ip: string,
path: string,
sshUser: string,
sshKeyPath?: string,
sshPort?: number
): Promise<BrowseResponse> {
try {
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
let privateKey: Buffer | undefined;
try {
privateKey = require('fs').readFileSync(keyPath);
} catch {
// ignore
}
const sshConfig: SSHConnectionConfig = {
host: ip,
port: sshPort || 22,
username: sshUser,
privateKey,
};
const conn = await connectSSH(sshConfig, 30000);
const command = `ls -la --time-style=long-iso "${path}" 2>/dev/null | tail -n +2`;
const output = await execSSH(conn, command);
conn.end();
const files: FileEntry[] = output.trim().split('\n').filter(Boolean).map(line => {
const parts = line.split(/\s+/);
const perms = parts[0];
const size = parseInt(parts[4], 10) || 0;
const modified = parts[5] + ' ' + parts[6];
const name = parts.slice(8).join(' ');
const type = perms.startsWith('d') ? 'directory' :
perms.startsWith('l') ? 'symlink' : 'file';
return {
name,
path: path === '/' ? `/${name}` : `${path}/${name}`,
type,
size,
modified,
};
});
return { path, files };
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : 'Failed to browse directory';
return { path, files: [], error: errMsg };
}
}
router.get('/files/browse/:host', async (req, res) => {
try {
const { host } = req.params;
const path = (req.query.path as string) || '/';
const hosts = getHostConfigs();
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
if (!hostConfig) {
return res.status(404).json({ path, files: [], error: `Host '${host}' not found` });
}
const result = await browseDirectory(
hostConfig.ip,
path,
hostConfig.sshUser,
hostConfig.sshKeyPath,
hostConfig.sshPort
);
res.json(result);
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : 'Failed to browse directory';
res.status(500).json({ path: req.query.path as string || '/', files: [], error: errMsg });
}
});
export default router;

167
server/routes/stats.ts Normal file
View File

@@ -0,0 +1,167 @@
/**
* SSH Stats API Endpoint
*
* GET /api/stats/:host/:container - Get real-time docker stats for a container
*/
import { Router } from 'express';
import { Client } from 'ssh2';
import { readFileSync } from 'fs';
import { homedir } from 'os';
import { getHostConfigs } from '../config';
import { StatsResponse } from '../types';
const router = Router();
interface SSHConnectionConfig {
host: string;
port?: number;
username: string;
privateKey?: Buffer;
}
async function connectSSH(config: SSHConnectionConfig, timeout: number = 30000): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timeoutId = setTimeout(() => {
conn.end();
reject(new Error('Connection timeout'));
}, timeout);
conn.on('ready', () => {
clearTimeout(timeoutId);
resolve(conn);
});
conn.on('error', (err) => {
clearTimeout(timeoutId);
reject(err);
});
conn.connect({
host: config.host,
port: config.port || 22,
username: config.username,
privateKey: config.privateKey,
readyTimeout: timeout,
});
});
}
async function execSSH(conn: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
let output = '';
stream.on('close', () => resolve(output));
stream.on('data', (data: Buffer) => { output += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
});
});
}
async function getContainerStats(
hostIp: string,
containerName: string,
sshUser: string,
sshKeyPath?: string,
sshPort?: number
): Promise<StatsResponse> {
// Load SSH key
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
let privateKey: Buffer | undefined;
try {
privateKey = readFileSync(keyPath);
} catch {
// If key file doesn't exist, try without it
}
const sshConfig: SSHConnectionConfig = {
host: hostIp,
port: sshPort || 22,
username: sshUser,
privateKey,
};
const conn = await connectSSH(sshConfig, 30000);
// Run docker stats with JSON format
const dockerStatsCmd = `docker stats ${containerName} --no-stream --format '{"cpu":"{{.CPUPerc}}","mem":"{{.MemPerc}}","net":"{{.NetIO}}"}'`;
const output = await execSSH(conn, dockerStatsCmd);
conn.end();
// Parse the output
try {
const parsed = JSON.parse(output.trim());
// Parse CPU (remove % and convert to number)
const cpu = parsed.cpu ? parseFloat(parsed.cpu.replace('%', '')) : 0;
// Parse Memory (remove % and convert to number)
const memory = parsed.mem ? parseFloat(parsed.mem.replace('%', '')) : 0;
// Parse Network I/O (format: "1.2MB / 800kB" -> rx: "1.2MB", tx: "800kB")
const netParts = parsed.net ? parsed.net.split(' / ') : ['0B', '0B'];
const rx = netParts[0]?.trim() || '0B';
const tx = netParts[1]?.trim() || '0B';
return {
cpu,
memory,
network: { rx, tx },
};
} catch (parseError) {
return {
cpu: 0,
memory: 0,
network: { rx: '0B', tx: '0B' },
error: 'Failed to parse stats output',
};
}
}
// GET /api/stats/:host/:container
router.get('/stats/:host/:container', async (req, res) => {
try {
const { host, container } = req.params;
// Find host config by name
const hosts = getHostConfigs();
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
if (!hostConfig) {
const response: StatsResponse = {
cpu: 0,
memory: 0,
network: { rx: '0B', tx: '0B' },
error: `Host '${host}' not found`,
};
return res.status(404).json(response);
}
const stats = await getContainerStats(
hostConfig.ip,
container,
hostConfig.sshUser,
hostConfig.sshKeyPath,
hostConfig.sshPort
);
res.json(stats);
} catch (error: any) {
const response: StatsResponse = {
cpu: 0,
memory: 0,
network: { rx: '0B', tx: '0B' },
error: error.message || 'Failed to get stats',
};
res.status(500).json(response);
}
});
export default router;

60
server/routes/terminal.ts Normal file
View File

@@ -0,0 +1,60 @@
import { Router } from 'express';
import { execSync } from 'child_process';
import { homedir } from 'os';
import { getHostConfigs } from '../config';
const router = Router();
interface TerminalRequest {
host: string;
command: string;
}
router.post('/terminal/exec', async (req, res) => {
try {
const { host: hostName, command }: TerminalRequest = req.body;
if (!hostName || !command) {
return res.status(400).json({ error: 'Missing host or command' });
}
const hosts = getHostConfigs();
const hostConfig = hosts.find(h => h.name === hostName);
if (!hostConfig) {
return res.status(404).json({ error: `Host not found: ${hostName}` });
}
const keyPath = (hostConfig.sshKeyPath || `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
const keyArg = `-i ${keyPath}`;
const portArg = hostConfig.sshPort && hostConfig.sshPort !== 22 ? `-p ${hostConfig.sshPort}` : '';
const fullCommand = `ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${hostConfig.sshUser}@${hostConfig.ip} ${command} 2>&1`;
const output = execSync(fullCommand, { encoding: 'utf-8', timeout: 30000 });
res.json({ output, error: null });
} catch (error: any) {
res.status(500).json({
output: '',
error: error.message || 'Command execution failed'
});
}
});
router.get('/terminal/hosts', async (_req, res) => {
try {
const hosts = getHostConfigs();
res.json({
hosts: hosts.map(h => ({
name: h.name,
ip: h.ip,
user: h.sshUser
}))
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

82
server/types.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* Backend Types for Homelab Topology Visualizer
*
* Type definitions for server-side operations including:
* - Host configuration for SSH connections
* - API response types for discovery, config, files, and stats
*/
export interface HostConfig {
/** Hostname for display in topology */
name: string;
/** IP address for SSH connection */
ip: string;
/** SSH username */
sshUser: string;
/** Optional path to SSH private key file */
sshKeyPath?: string;
/** Optional SSH port (defaults to 22) */
sshPort?: number;
/** Host type: proxmox, truenas, docker-host, etc */
hostType?: string;
}
export interface DiscoveryResponse {
/** Array of discovered hosts with their status */
hosts: Array<{
name: string;
ip: string;
online: boolean;
containers?: string[];
services?: string[];
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
error?: string;
}>;
/** Timestamp of discovery run */
timestamp: string;
/** Any errors encountered during discovery */
errors: string[];
}
export interface ConfigResponse {
/** Raw YAML configuration content */
yaml: string;
/** Path to the config file */
path: string;
/** Error message if config retrieval failed */
error?: string;
}
export interface VolumeMount {
/** Source path on host */
source: string;
/** Destination path in container */
destination: string;
/** Mount mode (e.g., 'rw', 'ro') */
mode: string;
}
export interface FilesResponse {
/** Array of volume mounts */
volumes: VolumeMount[];
/** Error message if file retrieval failed */
error?: string;
}
export interface NetworkStats {
/** Bytes received */
rx: number;
/** Bytes transmitted */
tx: number;
}
export interface StatsResponse {
/** CPU usage percentage */
cpu: number;
/** Memory usage percentage */
memory: number;
/** Network I/O statistics */
network: NetworkStats;
/** Error message if stats retrieval failed */
error?: string;
}

67
src/AGENTS.md Normal file
View File

@@ -0,0 +1,67 @@
# Src (React Frontend)
**Generated:** 2026-02-19
**Location:** src/
## OVERVIEW
React 18 + TypeScript frontend with Vite, Tailwind CSS, Zustand state, and React Flow graph visualization.
## STRUCTURE
```
src/
├── main.tsx # React entry point
├── App.tsx # Main app component, data loading
├── index.css # Tailwind CSS entry
├── vite-env.d.ts # Vite type definitions
├── components/
│ ├── Header.tsx # Top header with refresh
│ ├── LeftPanel.tsx # Host list sidebar
│ ├── RightPanel.tsx # Details panel (tabs)
│ └── Graph/
│ └── TopologyGraph.tsx # React Flow graph
├── services/
│ ├── discovery.ts # Data transformation
│ └── sshDiscovery.ts # SSH connection logic
├── store/
│ └── topologyStore.ts # Zustand global state
├── types/
│ └── index.ts # TypeScript interfaces
├── utils/
│ └── colors.ts # Color utilities
└── data/
└── staticConfig.ts # Static fallback data
```
## WHERE TO LOOK
| Task | File | Notes |
|------|------|-------|
| Add global state | src/store/topologyStore.ts | Zustand store |
| Modify graph | src/components/Graph/TopologyGraph.tsx | React Flow |
| Add new panel | src/components/*.tsx | Follow LeftPanel pattern |
## STATE MANAGEMENT
- Zustand store in `src/store/topologyStore.ts`
- Keys: nodes, edges, hosts, networkInfo, pollInterval, isLoading, lastUpdated
- Panels: leftPanelOpen, rightPanelOpen
## ANTI-PATTERNS
- **DO NOT store SSH credentials in localStorage** - security risk
- **DO NOT bypass the store** - use setState from useTopologyStore
## COMMANDS
```bash
npm run dev # Start Vite dev server (port 3000)
npm run build # TypeScript + Vite build to dist/
```
## NOTES
- API URL: configured via VITE_API_URL env var (default: http://localhost:3001)
- Falls back to simulated data if API call fails
- Polling interval: configurable via store (default in topologyStore.ts)

327
src/App.tsx Normal file
View File

@@ -0,0 +1,327 @@
import { useEffect, useRef, useCallback, useState, memo } from 'react';
import { ReactFlowProvider } from '@xyflow/react';
import { useShallow } from 'zustand/react/shallow';
import { io as ioClient, Socket } from 'socket.io-client';
import { useTopologyStore } from './store/topologyStore';
import {
defaultNetworkInfo,
discoverHosts,
convertToTopology,
DiscoveredHost
} from './services/discovery';
import Header from './components/Header';
import LeftPanel from './components/LeftPanel';
import RightPanel from './components/RightPanel';
import TopologyGraph from './components/Graph/TopologyGraph';
import CommandPalette from './components/CommandPalette';
import StaleWarning from './components/StaleWarning';
import TerminalPanel from './components/TerminalPanel';
import MetricsBar from './components/Dashboard/MetricsBar';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
interface ApiHost {
name: string;
ip: string;
online: boolean;
containers?: string[];
services?: string[];
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
}
interface ApiDiscoveryResponse {
hosts: ApiHost[];
timestamp: string;
errors: string[];
}
function App() {
const {
setNodes,
setEdges,
setNetworkInfo,
setHosts,
setLastUpdated,
setIsLoading,
setDataSource,
incrementFailures,
resetFailures,
setLastSuccessfulDiscovery,
} = useTopologyStore(useShallow((s) => ({
setNodes: s.setNodes,
setEdges: s.setEdges,
setNetworkInfo: s.setNetworkInfo,
setHosts: s.setHosts,
setLastUpdated: s.setLastUpdated,
setIsLoading: s.setIsLoading,
setDataSource: s.setDataSource,
incrementFailures: s.incrementFailures,
resetFailures: s.resetFailures,
setLastSuccessfulDiscovery: s.setLastSuccessfulDiscovery,
})));
const setConnectionStatus = useTopologyStore((s) => s.setConnectionStatus);
const {
leftPanelOpen,
rightPanelOpen,
isLoading,
pollInterval,
terminalOpen,
terminalHost,
} = useTopologyStore(useShallow((s) => ({
leftPanelOpen: s.leftPanelOpen,
rightPanelOpen: s.rightPanelOpen,
isLoading: s.isLoading,
pollInterval: s.pollInterval,
terminalOpen: s.terminalOpen,
terminalHost: s.terminalHost,
})));
const toggleCommandPalette = useTopologyStore((s) => s.toggleCommandPalette);
const closeTerminal = useTopologyStore((s) => s.closeTerminal);
const isLoadingRef = useRef(isLoading);
isLoadingRef.current = isLoading;
const pollIntervalRef = useRef(pollInterval);
pollIntervalRef.current = pollInterval;
const loadData = useCallback(async () => {
if (isLoadingRef.current) return;
setIsLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/api/discover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
const data: ApiDiscoveryResponse = await response.json();
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
name: h.name,
ip: h.ip,
online: h.online,
containers: (h.containers || []).map((c: string) => ({
name: c,
image: '',
status: 'running',
ports: [],
created: ''
})),
services: h.services,
vms: h.vms
}));
const { nodes, edges } = convertToTopology(discoveredHosts, defaultNetworkInfo);
const hosts = discoveredHosts.map(h => ({
name: h.name,
ip: h.ip,
type: (h.name === 'ubuntu' ? 'vm' :
h.name === 'proxmox' || h.name === 'truenas' ? 'physical' : 'rpi5') as 'vm' | 'physical' | 'rpi5' | 'container',
role: h.name === 'ubuntu' ? 'Primary Docker Host' :
h.name === 'grizzley' ? 'Edge Services' :
h.name === 'truenas' ? 'Storage (NAS)' :
h.name === 'proxmox' ? 'Hypervisor' : 'Host',
containers: h.containers.map(c => c.name),
services: h.services,
vms: h.vms
}));
setNodes(nodes);
setEdges(edges);
setNetworkInfo(defaultNetworkInfo);
setHosts(hosts);
setLastUpdated(new Date());
setDataSource('live');
setLastSuccessfulDiscovery(new Date());
resetFailures();
} else {
throw new Error(`API error: ${response.status}`);
}
} catch (error) {
console.error('Discovery failed, using simulated data:', error);
const discoveryResult = await discoverHosts(['ubuntu', 'grizzley', 'truenas', 'ice', 'panda', 'proxmox']);
const { nodes, edges } = convertToTopology(discoveryResult.hosts, defaultNetworkInfo);
const hosts = discoveryResult.hosts.map(h => ({
name: h.name,
ip: h.ip,
type: (h.name === 'ubuntu' ? 'vm' :
h.name === 'proxmox' || h.name === 'truenas' ? 'physical' : 'rpi5') as 'vm' | 'physical' | 'rpi5' | 'container',
role: h.name === 'ubuntu' ? 'Primary Docker Host' :
h.name === 'grizzley' ? 'Edge Services' :
h.name === 'truenas' ? 'Storage (NAS)' :
h.name === 'proxmox' ? 'Hypervisor' : 'Host',
containers: h.containers.map(c => c.name)
}));
setNodes(nodes);
setEdges(edges);
setNetworkInfo(defaultNetworkInfo);
setHosts(hosts);
setLastUpdated(new Date());
setDataSource('simulated');
incrementFailures();
} finally {
setIsLoading(false);
}
}, [setNodes, setEdges, setNetworkInfo, setHosts, setLastUpdated, setIsLoading, setDataSource, incrementFailures, resetFailures, setLastSuccessfulDiscovery]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
toggleCommandPalette();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleCommandPalette]);
useEffect(() => {
loadData();
}, []);
useEffect(() => {
const intervalId = setInterval(loadData, pollIntervalRef.current);
return () => clearInterval(intervalId);
}, [loadData]);
// --- WebSocket connection (websocket-engineer skill) ---
useEffect(() => {
const socket: Socket = ioClient(API_BASE_URL, {
transports: ['websocket', 'polling'],
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: Infinity,
});
socket.on('connect', () => {
setConnectionStatus('ws');
});
socket.on('disconnect', () => {
setConnectionStatus('polling');
});
socket.on('connect_error', () => {
setConnectionStatus('polling');
});
// Listen for real-time topology updates
socket.on('topology:update', (data: ApiDiscoveryResponse) => {
if (data?.hosts) {
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
name: h.name,
ip: h.ip,
online: h.online,
containers: (h.containers || []).map((c: string) => ({
name: c, image: '', status: 'running', ports: [], created: ''
})),
services: h.services,
vms: h.vms
}));
const { nodes, edges } = convertToTopology(discoveredHosts, defaultNetworkInfo);
setNodes(nodes);
setEdges(edges);
setLastUpdated(new Date());
}
});
return () => {
socket.disconnect();
};
}, [setConnectionStatus, setNodes, setEdges, setLastUpdated]);
return (
<ReactFlowProvider>
<div className="h-screen w-screen flex flex-col bg-slate-900">
{/* Skip link for accessibility */}
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<StaleWarning />
<Header onRefresh={loadData} isLoading={isLoading} />
<MetricsBar />
<div className="flex-1 flex overflow-hidden" role="main" id="main-content" tabIndex={-1}>
{leftPanelOpen && (
<LeftPanel />
)}
<div className="flex-1">
<TopologyGraph />
</div>
{rightPanelOpen && (
<RightPanel />
)}
</div>
<Footer />
<CommandPalette />
{terminalOpen && terminalHost && (
<TerminalPanel host={terminalHost} onClose={closeTerminal} />
)}
</div>
</ReactFlowProvider>
);
}
const Footer = memo(function Footer() {
const { lastUpdated, nodes, dataSource, pollInterval } = useTopologyStore(
useShallow((s) => ({
lastUpdated: s.lastUpdated,
nodes: s.nodes,
dataSource: s.dataSource,
pollInterval: s.pollInterval,
}))
);
const [countdown, setCountdown] = useState(Math.ceil(pollInterval / 1000));
const pollIntervalRef = useRef(pollInterval);
pollIntervalRef.current = pollInterval;
const formatTime = (date: Date | null) => {
if (!date) return 'Never';
return date.toLocaleTimeString();
};
useEffect(() => {
setCountdown(Math.ceil(pollInterval / 1000));
const intervalId = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) return Math.ceil(pollIntervalRef.current / 1000);
return prev - 1;
});
}, 1000);
return () => clearInterval(intervalId);
}, [lastUpdated, pollInterval]);
return (
<div
className="h-8 bg-slate-800 border-t border-slate-700 px-4 flex items-center justify-between text-xs text-slate-400"
aria-live="polite"
aria-atomic="true"
>
<span>Nodes: {nodes.length}</span>
<div className="flex items-center gap-4">
<span className={`px-2 py-0.5 rounded ${dataSource === 'live' ? 'bg-green-900 text-green-400' : 'bg-yellow-900 text-yellow-400'}`}>
{dataSource === 'live' ? 'Live' : 'Simulated'}
</span>
<span>Next refresh: {countdown}s</span>
<span>Last updated: {formatTime(lastUpdated)}</span>
</div>
</div>
);
});
export default App;

42
src/components/AGENTS.md Normal file
View File

@@ -0,0 +1,42 @@
# Components (React UI)
**Generated:** 2026-02-19
**Location:** src/components/
## OVERVIEW
React components for the topology visualization UI - header, panels, and graph.
## COMPONENTS
| Component | File | Purpose |
|-----------|------|---------|
| Header | Header.tsx | Top bar with title, refresh button, view mode |
| LeftPanel | LeftPanel.tsx | Collapsible host list sidebar |
| RightPanel | RightPanel.tsx | Collapsible details panel with tabs |
| TopologyGraph | Graph/TopologyGraph.tsx | React Flow graph rendering |
## ADDING A NEW COMPONENT
1. Create in `src/components/{Name}.tsx`
2. Export as default or named export
3. Import in parent (e.g., App.tsx)
## PATTERNS
- Use Zustand store: `import { useTopologyStore } from '../store/topologyStore'`
- Tailwind CSS classes for styling
- Props interfaces for type safety
- Follow existing component patterns (Header is good reference)
## SUB-COMPONENTS
- `Graph/TopologyGraph.tsx`: Uses @xyflow/react (React Flow) + dagre for auto-layout
- Nodes: Network → Host → Container → Volume hierarchy
- Edges: Connect related nodes
## NOTES
- Panels are collapsible via store state (leftPanelOpen, rightPanelOpen)
- Graph auto-layouts on data change using dagre
- All components use Tailwind for styling

View File

@@ -0,0 +1,359 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Search, Loader2, Network, HardDrive, Box, Database, ArrowLeftRight, ArrowUpDown, Router, Wifi, Monitor, Container, FolderTree, Folder, X } from 'lucide-react';
import { useTopologyStore } from '../store/topologyStore';
import { NodeType } from '../types';
interface Command {
id: string;
label: string;
description?: string;
icon: React.ReactNode;
category: 'action' | 'filter' | 'view' | 'orientation';
action: () => void;
}
const nodeTypeLabels: Record<NodeType, string> = {
gateway: 'Gateway',
vlan: 'VLAN',
wifi: 'WiFi',
host_physical: 'Physical Host',
host_vm: 'VM Host',
host_container: 'Container Host',
vm_lxc: 'LXC Container',
vm_qemu: 'QEMU VM',
systemd_service: 'Systemd Service',
service: 'Service',
volume: 'Volume',
mount: 'Mount',
path: 'Path',
};
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
gateway: <Router className="w-4 h-4" />,
vlan: <Network className="w-4 h-4" />,
wifi: <Wifi className="w-4 h-4" />,
host_physical: <HardDrive className="w-4 h-4" />,
host_vm: <Monitor className="w-4 h-4" />,
host_container: <Container className="w-4 h-4" />,
vm_lxc: <Container className="w-4 h-4" />,
vm_qemu: <Monitor className="w-4 h-4" />,
systemd_service: <Box className="w-4 h-4" />,
service: <Box className="w-4 h-4" />,
volume: <Database className="w-4 h-4" />,
mount: <FolderTree className="w-4 h-4" />,
path: <Folder className="w-4 h-4" />
};
function fuzzyMatch(pattern: string, text: string): boolean {
const patternLower = pattern.toLowerCase();
const textLower = text.toLowerCase();
let patternIdx = 0;
for (let i = 0; i < textLower.length && patternIdx < patternLower.length; i++) {
if (textLower[i] === patternLower[patternIdx]) {
patternIdx++;
}
}
return patternIdx === patternLower.length;
}
interface CommandPaletteProps {
onRefresh?: () => Promise<void>;
}
export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
const {
commandPaletteOpen,
toggleCommandPalette,
typeFilters,
toggleTypeFilter,
setViewMode,
setOrientation
} = useTopologyStore();
const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const commands = useMemo<Command[]>(() => {
const cmds: Command[] = [
{
id: 'refresh',
label: 'Refresh Discovery',
description: 'Reload topology data from hosts',
icon: <Loader2 className="w-4 h-4" />,
category: 'action',
action: async () => {
if (onRefresh) {
await onRefresh();
}
toggleCommandPalette();
}
},
{
id: 'view-full',
label: 'Set View: Full',
description: 'Show complete topology',
icon: <Network className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('full');
toggleCommandPalette();
}
},
{
id: 'view-network',
label: 'Set View: Network',
description: 'Show network infrastructure',
icon: <Network className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('network');
toggleCommandPalette();
}
},
{
id: 'view-host',
label: 'Set View: Hosts',
description: 'Show hosts and containers',
icon: <HardDrive className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('host');
toggleCommandPalette();
}
},
{
id: 'view-service',
label: 'Set View: Services',
description: 'Show services and volumes',
icon: <Box className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('service');
toggleCommandPalette();
}
},
{
id: 'view-filesystem',
label: 'Set View: Files',
description: 'Show filesystem hierarchy',
icon: <Database className="w-4 h-4" />,
category: 'view',
action: () => {
setViewMode('filesystem');
toggleCommandPalette();
}
},
{
id: 'orientation-lr',
label: 'Set Orientation: Left to Right',
icon: <ArrowLeftRight className="w-4 h-4" />,
category: 'orientation',
action: () => {
setOrientation('LR');
toggleCommandPalette();
}
},
{
id: 'orientation-tb',
label: 'Set Orientation: Top to Bottom',
icon: <ArrowUpDown className="w-4 h-4" />,
category: 'orientation',
action: () => {
setOrientation('TB');
toggleCommandPalette();
}
}
];
const nodeTypes: NodeType[] = [
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm',
'host_container', 'service', 'volume', 'mount', 'path'
];
nodeTypes.forEach((type) => {
const isActive = typeFilters.includes(type);
cmds.push({
id: `filter-${type}`,
label: `${isActive ? 'Disable' : 'Enable'} Filter: ${nodeTypeLabels[type]}`,
description: `${isActive ? 'Hide' : 'Show'} ${nodeTypeLabels[type]} nodes`,
icon: nodeTypeIcons[type],
category: 'filter',
action: () => {
toggleTypeFilter(type);
toggleCommandPalette();
}
});
});
return cmds;
}, [typeFilters, onRefresh, setViewMode, setOrientation, toggleTypeFilter, toggleCommandPalette]);
const filteredCommands = useMemo(() => {
if (!search.trim()) return commands;
return commands.filter(cmd => fuzzyMatch(search, cmd.label));
}, [commands, search]);
useEffect(() => {
setSelectedIndex(0);
}, [filteredCommands.length]);
useEffect(() => {
if (commandPaletteOpen) {
setSearch('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [commandPaletteOpen]);
useEffect(() => {
if (listRef.current) {
const selectedEl = listRef.current.children[selectedIndex] as HTMLElement;
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' });
}
}
}, [selectedIndex]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, filteredCommands.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (filteredCommands[selectedIndex]) {
filteredCommands[selectedIndex].action();
}
break;
case 'Escape':
e.preventDefault();
toggleCommandPalette();
break;
}
}, [filteredCommands, selectedIndex, toggleCommandPalette]);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
toggleCommandPalette();
}
}, [toggleCommandPalette]);
if (!commandPaletteOpen) return null;
const categoryLabels: Record<Command['category'], string> = {
action: 'Actions',
filter: 'Filters',
view: 'View Mode',
orientation: 'Orientation'
};
const groupedCommands = filteredCommands.reduce((acc, cmd) => {
if (!acc[cmd.category]) {
acc[cmd.category] = [];
}
acc[cmd.category].push(cmd);
return acc;
}, {} as Record<Command['category'], Command[]>);
let globalIndex = 0;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/60 backdrop-blur-sm"
onClick={handleBackdropClick}
>
<div className="w-full max-w-xl bg-slate-800 rounded-xl shadow-2xl border border-slate-700 overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-700">
<Search className="w-5 h-5 text-slate-400 flex-shrink-0" />
<input
ref={inputRef}
type="text"
placeholder="Type a command..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent text-white placeholder-slate-400 outline-none text-base"
/>
<button
onClick={toggleCommandPalette}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-700 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
<div ref={listRef} className="max-h-[50vh] overflow-y-auto py-2">
{filteredCommands.length === 0 ? (
<div className="px-4 py-8 text-center text-slate-400">
No commands found
</div>
) : (
Object.entries(groupedCommands).map(([category, cmds]) => (
<div key={category}>
<div className="px-4 py-2 text-xs font-medium text-slate-500 uppercase tracking-wider">
{categoryLabels[category as Command['category']]}
</div>
{cmds.map((cmd) => {
const currentIndex = globalIndex++;
const isSelected = currentIndex === selectedIndex;
return (
<button
key={cmd.id}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(currentIndex)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected
? 'bg-indigo-500/20 text-white'
: 'text-slate-300 hover:bg-slate-700/50'
}`}
>
<span className={`flex-shrink-0 ${isSelected ? 'text-indigo-400' : 'text-slate-400'}`}>
{cmd.icon}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{cmd.label}</div>
{cmd.description && (
<div className="text-xs text-slate-500 truncate">{cmd.description}</div>
)}
</div>
{isSelected && (
<span className="text-xs text-slate-500 flex-shrink-0">
Enter
</span>
)}
</button>
);
})}
</div>
))
)}
</div>
<div className="px-4 py-2 border-t border-slate-700 flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400"></kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Enter</kbd>
Select
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Esc</kbd>
Close
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useMemo } from 'react';
import { useTopologyStore } from '../../store/topologyStore';
import { useShallow } from 'zustand/react/shallow';
/**
* HostChart — Visual bar chart showing container/service counts per host
* (data-visualizer skill — accessible color palette, responsive design)
*
* Renders a pure-CSS horizontal bar chart — no external charting library needed.
* Falls back gracefully when no hosts have containers.
*/
// Colorblind-safe palette from data-visualizer skill
const HOST_COLORS = [
'#0066CC', // Blue
'#CC6600', // Orange
'#7A00CC', // Purple
'#00CC66', // Green
'#CC0066', // Magenta
'#009E73', // Teal
'#56B4E9', // Sky Blue
'#E69F00', // Amber
];
export default function HostChart() {
const { nodes } = useTopologyStore(
useShallow((s) => ({ nodes: s.nodes }))
);
const hostData = useMemo(() => {
// Find host-type nodes
const hosts = nodes.filter(
(n) =>
n.type === 'host_physical' ||
n.type === 'host_vm' ||
n.type === 'host_container'
);
// Count children (services/containers) per host
return hosts
.map((host) => {
const children = nodes.filter((n) => n.data.parentId === host.id);
const running = children.filter((n) => n.data.status === 'running').length;
const stopped = children.filter((n) => n.data.status === 'stopped').length;
return {
name: host.name,
total: children.length,
running,
stopped,
status: host.data.status,
};
})
.sort((a, b) => b.total - a.total);
}, [nodes]);
const maxCount = Math.max(...hostData.map((h) => h.total), 1);
if (hostData.length === 0) {
return (
<div className="p-4 text-slate-500 text-sm text-center">
No host data available
</div>
);
}
return (
<div className="p-3 space-y-2">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">
Services per Host
</h3>
{hostData.map((host, idx) => (
<div key={host.name} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-300 font-medium truncate max-w-[120px]">
{host.name}
</span>
<span className="text-slate-500">
<span className="text-green-400">{host.running}</span>
{host.stopped > 0 && (
<span className="text-red-400 ml-1">+{host.stopped}</span>
)}
</span>
</div>
<div className="h-2 bg-slate-700/50 rounded-full overflow-hidden">
{/* Running portion */}
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${(host.total / maxCount) * 100}%`,
background: `linear-gradient(90deg, ${HOST_COLORS[idx % HOST_COLORS.length]}CC, ${HOST_COLORS[idx % HOST_COLORS.length]}88)`,
}}
role="progressbar"
aria-valuenow={host.total}
aria-valuemin={0}
aria-valuemax={maxCount}
aria-label={`${host.name}: ${host.running} running, ${host.stopped} stopped`}
/>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { useTopologyStore } from '../../store/topologyStore';
import { useShallow } from 'zustand/react/shallow';
import {
Activity,
Server,
Container,
Wifi,
Clock,
} from 'lucide-react';
/**
* MetricsBar — KPI-style metrics bar (data-visualizer + infrastructure-monitoring skills)
* Shows key topology stats: total hosts, running containers, online %, last discovery time.
*/
export default function MetricsBar() {
const { nodes, lastUpdated, connectionStatus } = useTopologyStore(
useShallow((s) => ({
nodes: s.nodes,
lastUpdated: s.lastUpdated,
connectionStatus: s.connectionStatus,
}))
);
const metrics = useMemo(() => {
const hosts = nodes.filter(
(n) =>
n.type === 'host_physical' ||
n.type === 'host_vm' ||
n.type === 'host_container'
);
const containers = nodes.filter(
(n) =>
n.type === 'service' ||
n.type === 'vm_lxc' ||
n.type === 'vm_qemu'
);
const running = containers.filter((n) => n.data.status === 'running');
const onlineHosts = hosts.filter((n) => n.data.status === 'running');
return {
totalHosts: hosts.length,
onlineHosts: onlineHosts.length,
totalContainers: containers.length,
runningContainers: running.length,
uptimePercent:
hosts.length > 0
? Math.round((onlineHosts.length / hosts.length) * 100)
: 0,
};
}, [nodes]);
const connectionColor =
connectionStatus === 'ws'
? 'text-green-400'
: connectionStatus === 'polling'
? 'text-yellow-400'
: 'text-red-400';
const connectionLabel =
connectionStatus === 'ws'
? 'WebSocket'
: connectionStatus === 'polling'
? 'Polling'
: 'Disconnected';
return (
<div className="h-10 bg-slate-800/80 border-b border-slate-700/50 px-4 flex items-center gap-6 text-xs">
{/* Hosts */}
<div className="flex items-center gap-1.5" title="Hosts Online">
<Server size={13} className="text-emerald-400" />
<span className="text-slate-300 font-medium">
{metrics.onlineHosts}/{metrics.totalHosts}
</span>
<span className="text-slate-500">hosts</span>
</div>
{/* Containers */}
<div className="flex items-center gap-1.5" title="Running Containers">
<Container size={13} className="text-cyan-400" />
<span className="text-slate-300 font-medium">
{metrics.runningContainers}/{metrics.totalContainers}
</span>
<span className="text-slate-500">containers</span>
</div>
{/* Uptime */}
<div className="flex items-center gap-1.5" title="Host Uptime">
<Activity size={13} className="text-amber-400" />
<span className="text-slate-300 font-medium">
{metrics.uptimePercent}%
</span>
<span className="text-slate-500">uptime</span>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Connection status */}
<div className="flex items-center gap-1.5" title={`Connection: ${connectionLabel}`}>
<Wifi size={13} className={connectionColor} />
<span className={`font-medium ${connectionColor}`}>
{connectionLabel}
</span>
</div>
{/* Last updated */}
{lastUpdated && (
<div className="flex items-center gap-1.5" title="Last Discovery">
<Clock size={13} className="text-slate-500" />
<span className="text-slate-500">
{lastUpdated.toLocaleTimeString()}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useState, useEffect } from 'react';
import { Folder, File, ArrowLeft, X, RefreshCw, Terminal as TerminalIcon } from 'lucide-react';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
interface FileEntry {
name: string;
path: string;
type: 'file' | 'directory' | 'symlink';
size: number;
modified: string;
}
interface FileBrowserProps {
host: string;
initialPath?: string;
onClose: () => void;
}
export default function FileBrowser({ host, initialPath = '/', onClose }: FileBrowserProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFiles = async (path: string) => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/files/browse/${host}?path=${encodeURIComponent(path)}`
);
const data = await response.json();
if (data.error) {
setError(data.error);
setFiles([]);
} else {
setFiles(data.files || []);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to fetch files';
setError(msg);
setFiles([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFiles(currentPath);
}, [host, currentPath]);
const navigateTo = (path: string) => {
setCurrentPath(path);
};
const goUp = () => {
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/');
navigateTo(newPath);
};
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
};
const getFileIcon = (type: string) => {
if (type === 'directory') return <Folder className="w-4 h-4 text-amber-400" />;
if (type === 'symlink') return <TerminalIcon className="w-4 h-4 text-blue-400" />;
return <File className="w-4 h-4 text-slate-400" />;
};
return (
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
<div className="flex items-center gap-2">
<span className="text-slate-400">/</span>
<span className="text-cyan-400 font-medium">{host}</span>
<span className="text-slate-500">:</span>
<input
type="text"
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && navigateTo(e.currentTarget.value)}
className="bg-slate-700 text-slate-200 px-2 py-1 rounded text-sm font-mono w-64"
/>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => fetchFiles(currentPath)}
className="p-1 hover:bg-slate-700 rounded transition-colors"
title="Refresh"
>
<RefreshCw className="w-4 h-4 text-slate-400" />
</button>
<button
onClick={onClose}
className="p-1 hover:bg-slate-700 rounded transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-2">
{loading ? (
<div className="flex items-center justify-center h-full">
<RefreshCw className="w-6 h-6 text-slate-400 animate-spin" />
</div>
) : error ? (
<div className="text-red-400 p-4">{error}</div>
) : (
<div className="font-mono text-sm">
{currentPath !== '/' && (
<button
onClick={goUp}
className="flex items-center gap-2 w-full px-2 py-1 hover:bg-slate-800 rounded"
>
<ArrowLeft className="w-4 h-4 text-slate-400" />
<span className="text-slate-400">..</span>
</button>
)}
{files.map((file) => (
<button
key={file.path}
onClick={() => file.type === 'directory' && navigateTo(file.path)}
className={`flex items-center gap-2 w-full px-2 py-1 hover:bg-slate-800 rounded ${
file.type !== 'directory' ? 'cursor-default' : ''
}`}
>
{getFileIcon(file.type)}
<span className="text-slate-200 flex-1 text-left">{file.name}</span>
<span className="text-slate-500 text-xs">{formatSize(file.size)}</span>
<span className="text-slate-600 text-xs w-24">{file.modified}</span>
</button>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
import { useCallback, useMemo, memo } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
Node,
Edge,
NodeProps,
Handle,
Position,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import { useShallow } from 'zustand/react/shallow';
import { useTopologyStore } from '../../store/topologyStore';
import { getNodeColor, getStatusColor } from '../../utils/colors';
import { Server, Network, Wifi, Box, Database, Folder } from 'lucide-react';
import { NodeType, ServiceCategory } from '../../types';
const nodeIcons: Record<NodeType, React.ReactNode> = {
gateway: <Network className="w-5 h-5" />,
vlan: <Network className="w-4 h-4" />,
wifi: <Wifi className="w-4 h-4" />,
host_physical: <Server className="w-5 h-5" />,
host_vm: <Server className="w-5 h-5" />,
host_container: <Server className="w-5 h-5" />,
vm_lxc: <Box className="w-4 h-4" />,
vm_qemu: <Server className="w-5 h-5" />,
systemd_service: <Box className="w-4 h-4" />,
service: <Box className="w-4 h-4" />,
volume: <Database className="w-4 h-4" />,
mount: <Database className="w-4 h-4" />,
path: <Folder className="w-4 h-4" />,
};
const CustomNode = memo(function CustomNode({ data, selected, id }: NodeProps) {
const highlightPath = useTopologyStore((s) => s.highlightPath);
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string };
const nodeColor = getNodeColor(nodeData.type || 'service', nodeData.category);
const statusColor = getStatusColor(nodeData.status || 'unknown');
const isHighlighted = highlightPath.includes(id);
const isDimmed = highlightPath.length > 0 && !isHighlighted;
return (
<div
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 ${selected
? 'border-sky-400 shadow-lg shadow-sky-400/20 scale-[1.02]'
: isHighlighted
? 'border-indigo-400 shadow-lg shadow-indigo-400/20'
: 'border-slate-600 hover:border-slate-500 hover:shadow-md hover:shadow-slate-700/30 hover:scale-[1.01]'
}`}
style={{
backgroundColor: isDimmed ? '#0F172A' : '#1E293B',
minWidth: '140px',
opacity: isDimmed ? 0.4 : 1
}}
role="treeitem"
aria-label={`${nodeData.label || 'Node'}, ${nodeData.type?.replace(/_/g, ' ') || 'unknown type'}, ${nodeData.status || 'unknown status'}`}
>
<Handle type="target" position={Position.Left} className="!bg-slate-400" />
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${nodeColor}20` }}
>
<div style={{ color: nodeColor }} aria-hidden="true">
{nodeIcons[nodeData.type || 'service']}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white truncate">
{nodeData.label}
</div>
{nodeData.ip && (
<div className="text-xs text-slate-500 font-mono">
{nodeData.ip}
</div>
)}
</div>
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: statusColor }}
aria-label={`Status: ${nodeData.status || 'unknown'}`}
/>
</div>
<Handle type="source" position={Position.Right} className="!bg-slate-400" />
</div>
);
});
const nodeTypes = {
custom: CustomNode,
};
const nodeWidth = 180;
const nodeHeight = 70;
function getLayoutedElements(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
if (nodes.length === 0) return { nodes: [], edges: [] };
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 100 });
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
if (!nodeWithPosition) return node;
return {
...node,
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
},
};
});
return { nodes: layoutedNodes, edges };
}
export default function TopologyGraph() {
const {
edges: storeEdges,
selectedNodeId,
setSelectedNode,
getFilteredNodes,
orientation,
viewMode,
highlightPath
} = useTopologyStore(useShallow((s) => ({
edges: s.edges,
selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode,
getFilteredNodes: s.getFilteredNodes,
orientation: s.orientation,
viewMode: s.viewMode,
highlightPath: s.highlightPath,
})));
// Memoize the layout computation instead of useState + useEffect
const { nodes, edges } = useMemo(() => {
const filteredNodes = getFilteredNodes();
if (filteredNodes.length === 0) {
return { nodes: [] as Node[], edges: [] as Edge[] };
}
const nodeIds = new Set(filteredNodes.map(n => n.id));
const newNodes: Node[] = filteredNodes.map(node => ({
id: node.id,
type: 'custom',
position: { x: 0, y: 0 },
data: {
label: node.name,
type: node.type,
status: node.data.status,
category: node.data.category,
ip: node.data.ip,
},
selected: node.id === selectedNodeId,
}));
const newEdges: Edge[] = storeEdges
.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
.map(edge => {
const isPathEdge = highlightPath.includes(edge.source) && highlightPath.includes(edge.target);
const isSelected = edge.source === selectedNodeId || edge.target === selectedNodeId;
return {
id: edge.id,
source: edge.source,
target: edge.target,
type: 'smoothstep',
animated: isSelected || isPathEdge,
style: {
stroke: isSelected
? '#38BDF8'
: isPathEdge
? '#818CF8'
: '#475569',
strokeWidth: isSelected || isPathEdge
? 2
: 1,
opacity: highlightPath.length > 0 && !isPathEdge ? 0.3 : 1
},
markerEnd: {
type: 'arrowclosed' as const,
color: isSelected
? '#38BDF8'
: isPathEdge
? '#818CF8'
: '#475569',
},
};
});
return getLayoutedElements(newNodes, newEdges, orientation);
}, [getFilteredNodes, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNode(node.id);
}, [setSelectedNode]);
const onPaneClick = useCallback(() => {
setSelectedNode(null);
}, [setSelectedNode]);
return (
<div className="w-full h-full" role="application" aria-label="Network topology graph">
<ReactFlow
nodes={nodes}
edges={edges}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
defaultEdgeOptions={{
type: 'smoothstep',
}}
proOptions={{ hideAttribution: true }}
>
<Background color="#334155" gap={20} size={1} />
<Controls className="!bg-slate-700 !border-slate-600 !rounded-lg !shadow-lg" />
<MiniMap
className="!bg-slate-800 !border-slate-700"
nodeColor={(node) => getNodeColor(node.data?.type as NodeType || 'service', (node.data?.category as ServiceCategory) || undefined)}
maskColor="rgba(15, 23, 42, 0.8)"
/>
</ReactFlow>
</div>
);
}

244
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,244 @@
import { useCallback } from 'react';
import { Search, Loader2, Network, HardDrive, Box, Database, Link, ArrowLeftRight, ArrowUpDown, Router, Wifi, Monitor, Container, FolderTree, Folder, Settings } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useTopologyStore, Orientation, StatusFilter } from '../store/topologyStore';
import { ViewMode, NodeType } from '../types';
import { getNodeColor } from '../utils/colors';
interface HeaderProps {
onRefresh?: () => Promise<void>;
isLoading?: boolean;
}
const viewModes: { mode: ViewMode; label: string; icon: React.ReactNode }[] = [
{ mode: 'full', label: 'Full', icon: <Link className="w-4 h-4" /> },
{ mode: 'network', label: 'Network', icon: <Network className="w-4 h-4" /> },
{ mode: 'host', label: 'Hosts', icon: <HardDrive className="w-4 h-4" /> },
{ mode: 'service', label: 'Services', icon: <Box className="w-4 h-4" /> },
{ mode: 'filesystem', label: 'Files', icon: <Database className="w-4 h-4" /> },
];
const orientations: { value: Orientation; label: string; icon: React.ReactNode }[] = [
{ value: 'LR', label: 'Left to Right', icon: <ArrowLeftRight className="w-4 h-4" /> },
{ value: 'TB', label: 'Top to Bottom', icon: <ArrowUpDown className="w-4 h-4" /> },
];
const nodeTypeFilters: { type: NodeType; icon: React.ReactNode }[] = [
{ type: 'gateway', icon: <Router className="w-4 h-4" /> },
{ type: 'vlan', icon: <Network className="w-4 h-4" /> },
{ type: 'wifi', icon: <Wifi className="w-4 h-4" /> },
{ type: 'host_physical', icon: <HardDrive className="w-4 h-4" /> },
{ type: 'host_vm', icon: <Monitor className="w-4 h-4" /> },
{ type: 'host_container', icon: <Container className="w-4 h-4" /> },
{ type: 'service', icon: <Box className="w-4 h-4" /> },
{ type: 'volume', icon: <Database className="w-4 h-4" /> },
{ type: 'mount', icon: <FolderTree className="w-4 h-4" /> },
{ type: 'path', icon: <Folder className="w-4 h-4" /> },
];
export default function Header({ onRefresh, isLoading: externalLoading }: HeaderProps) {
const {
viewMode,
setViewMode,
orientation,
setOrientation,
searchQuery,
setSearchQuery,
typeFilters,
toggleTypeFilter,
statusFilter,
setStatusFilter,
toggleLeftPanel,
toggleRightPanel,
leftPanelOpen,
rightPanelOpen,
isLoading: storeLoading,
pollInterval,
setPollInterval
} = useTopologyStore(useShallow((s) => ({
viewMode: s.viewMode,
setViewMode: s.setViewMode,
orientation: s.orientation,
setOrientation: s.setOrientation,
searchQuery: s.searchQuery,
setSearchQuery: s.setSearchQuery,
typeFilters: s.typeFilters,
toggleTypeFilter: s.toggleTypeFilter,
statusFilter: s.statusFilter,
setStatusFilter: s.setStatusFilter,
toggleLeftPanel: s.toggleLeftPanel,
toggleRightPanel: s.toggleRightPanel,
leftPanelOpen: s.leftPanelOpen,
rightPanelOpen: s.rightPanelOpen,
isLoading: s.isLoading,
pollInterval: s.pollInterval,
setPollInterval: s.setPollInterval,
})));
const loading = externalLoading ?? storeLoading;
const handleRefresh = useCallback(async () => {
if (onRefresh) {
await onRefresh();
}
}, [onRefresh]);
return (
<header className="h-14 bg-slate-800 border-b border-slate-700 px-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-indigo-500 rounded-lg flex items-center justify-center">
<Network className="w-5 h-5 text-white" />
</div>
<h1 className="text-lg font-semibold text-white">Homelab Topology</h1>
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="flex items-center gap-1" role="toolbar" aria-label="View mode">
{viewModes.map(({ mode, label, icon }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
aria-pressed={viewMode === mode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${viewMode === mode
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/50'
: 'text-slate-400 hover:text-white hover:bg-slate-700'
}`}
>
{icon}
{label}
</button>
))}
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="flex items-center gap-2">
<label htmlFor="orientation-select" className="visually-hidden">Graph orientation</label>
<select
id="orientation-select"
value={orientation}
onChange={(e) => setOrientation(e.target.value as Orientation)}
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
>
{orientations.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="flex items-center gap-1" role="toolbar" aria-label="Node type filters">
{nodeTypeFilters.map(({ type, icon }) => {
const isActive = typeFilters.includes(type);
const color = getNodeColor(type);
return (
<button
key={type}
onClick={() => toggleTypeFilter(type)}
aria-pressed={isActive}
aria-label={`Filter ${type.replace(/_/g, ' ')}`}
className={`p-2 rounded-md transition-colors ${isActive
? 'border'
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-700'
}`}
style={isActive ? {
backgroundColor: `${color}20`,
borderColor: `${color}50`,
color: color
} : undefined}
>
{icon}
</button>
);
})}
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="flex items-center gap-2">
<label htmlFor="status-filter" className="visually-hidden">Status filter</label>
<select
id="status-filter"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
>
<option value="all">All Status</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
</select>
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-slate-400" aria-hidden="true" />
<label htmlFor="poll-interval" className="visually-hidden">Poll interval</label>
<select
id="poll-interval"
value={pollInterval}
onChange={(e) => setPollInterval(parseInt(e.target.value, 10))}
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
>
<option value={10000}>10 seconds</option>
<option value={30000}>30 seconds</option>
<option value={60000}>1 minute</option>
<option value={300000}>5 minutes</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" aria-hidden="true" />
<label htmlFor="node-search" className="visually-hidden">Search nodes</label>
<input
id="node-search"
type="text"
placeholder="Search nodes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-64 h-9 pl-9 pr-4 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:border-indigo-500"
/>
</div>
<button
onClick={handleRefresh}
disabled={loading}
aria-label={loading ? 'Loading data' : 'Refresh data'}
className="h-9 px-3 flex items-center gap-2 bg-slate-700 hover:bg-slate-600 border border-slate-600 rounded-lg text-sm text-slate-300 transition-colors disabled:opacity-50"
>
<Loader2 className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} aria-hidden="true" />
{loading ? 'Loading...' : 'Refresh'}
</button>
<div className="h-6 w-px bg-slate-600" />
<button
onClick={toggleLeftPanel}
aria-label={leftPanelOpen ? 'Hide child nodes panel' : 'Show child nodes panel'}
aria-pressed={leftPanelOpen}
className={`p-2 rounded-lg transition-colors ${leftPanelOpen ? 'bg-indigo-500/20 text-indigo-400' : 'text-slate-400 hover:text-white hover:bg-slate-700'
}`}
>
<Box className="w-5 h-5" aria-hidden="true" />
</button>
<button
onClick={toggleRightPanel}
aria-label={rightPanelOpen ? 'Hide details panel' : 'Show details panel'}
aria-pressed={rightPanelOpen}
className={`p-2 rounded-lg transition-colors ${rightPanelOpen ? 'bg-indigo-500/20 text-indigo-400' : 'text-slate-400 hover:text-white hover:bg-slate-700'
}`}
>
<Database className="w-5 h-5" aria-hidden="true" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,127 @@
import { useMemo } from 'react';
import { ChevronRight, Server, Network, Wifi, Box, Database, Folder } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useTopologyStore } from '../store/topologyStore';
import { getNodeColor } from '../utils/colors';
import { TopologyNode, NodeType } from '../types';
import HostChart from './Dashboard/HostChart';
const typeIcons: Record<NodeType, React.ReactNode> = {
gateway: <Network className="w-4 h-4" />,
vlan: <Network className="w-4 h-4" />,
wifi: <Wifi className="w-4 h-4" />,
host_physical: <Server className="w-4 h-4" />,
host_vm: <Server className="w-4 h-4" />,
host_container: <Server className="w-4 h-4" />,
vm_lxc: <Box className="w-4 h-4" />,
vm_qemu: <Server className="w-4 h-4" />,
systemd_service: <Box className="w-4 h-4" />,
service: <Box className="w-4 h-4" />,
volume: <Database className="w-4 h-4" />,
mount: <Database className="w-4 h-4" />,
path: <Folder className="w-4 h-4" />,
};
const typeLabels: Record<NodeType, string> = {
gateway: 'Gateway',
vlan: 'VLAN',
wifi: 'WiFi',
host_physical: 'Physical',
host_vm: 'VM',
host_container: 'Container',
vm_lxc: 'LXC',
vm_qemu: 'QEMU',
systemd_service: 'Systemd',
service: 'Service',
volume: 'Volume',
mount: 'Mount',
path: 'Path',
};
export default function LeftPanel() {
const { nodes, selectedNodeId, setSelectedNode } = useTopologyStore(
useShallow((s) => ({
nodes: s.nodes,
selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode,
}))
);
const selectedNode = nodes.find(n => n.id === selectedNodeId);
const childNodes = nodes.filter(n => n.data.parentId === selectedNodeId);
const groupedChildren = useMemo(() => {
return childNodes.reduce((acc, node) => {
if (!acc[node.type]) acc[node.type] = [];
acc[node.type].push(node);
return acc;
}, {} as Record<NodeType, TopologyNode[]>);
}, [childNodes]);
return (
<aside className="w-72 bg-slate-800 border-r border-slate-700 flex flex-col" aria-label="Child nodes panel">
<div className="h-12 px-4 flex items-center border-b border-slate-700">
<h2 className="text-sm font-semibold text-white uppercase tracking-wide">
{selectedNode ? 'Child Nodes' : 'Select a Node'}
</h2>
{childNodes.length > 0 && (
<span className="ml-2 px-2 py-0.5 bg-slate-700 text-slate-300 text-xs rounded-full">
{childNodes.length}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto">
{!selectedNode ? (
<div className="p-4 text-center text-slate-500 text-sm">
Click on a node to view its child nodes
</div>
) : childNodes.length === 0 ? (
<div className="p-4 text-center text-slate-500 text-sm">
No child nodes
</div>
) : (
<nav className="p-2" aria-label="Child node list">
{Object.entries(groupedChildren).map(([type, typeNodes]) => (
<div key={type} className="mb-3">
<div className="px-2 py-1 text-xs font-medium text-slate-500 uppercase tracking-wide">
{typeLabels[type as NodeType]}s ({typeNodes.length})
</div>
{typeNodes.map(node => (
<button
key={node.id}
onClick={() => setSelectedNode(node.id)}
className={`w-full px-3 py-2 flex items-center gap-3 rounded-lg transition-colors text-left ${selectedNodeId === node.id
? 'bg-indigo-500/20 text-indigo-300'
: 'text-slate-300 hover:bg-slate-700'
}`}
>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${getNodeColor(node.type, node.data.category)}20` }}
>
<div style={{ color: getNodeColor(node.type, node.data.category) }}>
{typeIcons[node.type]}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{node.name}</div>
{node.data.ip && (
<div className="text-xs text-slate-500">{node.data.ip}</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-slate-500" aria-hidden="true" />
</button>
))}
</div>
))}
</nav>
)}
</div>
{/* Host metrics chart (data-visualizer skill) */}
<div className="border-t border-slate-700/50">
<HostChart />
</div>
</aside>
);
}

View File

@@ -0,0 +1,332 @@
import { useState, useCallback } from 'react';
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useTopologyStore } from '../store/topologyStore';
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors';
import { TopologyNode } from '../types';
import FileBrowser from './FileBrowser';
type TabId = 'details' | 'config' | 'files' | 'usage' | 'importance';
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
{ id: 'details', label: 'Details', icon: <Info className="w-4 h-4" /> },
{ id: 'config', label: 'Config', icon: <FileCode className="w-4 h-4" /> },
{ id: 'files', label: 'Files', icon: <FolderOpen className="w-4 h-4" /> },
{ id: 'usage', label: 'Usage', icon: <BarChart3 className="w-4 h-4" /> },
{ id: 'importance', label: 'Importance', icon: <Star className="w-4 h-4" /> },
];
export default function RightPanel() {
const { nodes, selectedNodeId, setSelectedNode, openTerminal } = useTopologyStore(
useShallow((s) => ({
nodes: s.nodes,
selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode,
openTerminal: s.openTerminal,
}))
);
const [activeTab, setActiveTab] = useState<TabId>('details');
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const selectedNode = nodes.find(n => n.id === selectedNodeId);
const handleTabKeyDown = useCallback((e: React.KeyboardEvent, currentIndex: number) => {
let newIndex = currentIndex;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
newIndex = (currentIndex + 1) % tabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
e.preventDefault();
newIndex = 0;
} else if (e.key === 'End') {
e.preventDefault();
newIndex = tabs.length - 1;
} else {
return;
}
setActiveTab(tabs[newIndex].id);
// Focus the new tab button
const tabEl = document.getElementById(`tab-${tabs[newIndex].id}`);
tabEl?.focus();
}, []);
if (!selectedNode) {
return (
<aside className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col items-center justify-center p-4" aria-label="Node details panel">
<div className="text-slate-500 text-sm text-center">
Select a node to view its details
</div>
</aside>
);
}
const nodeColor = getNodeColor(selectedNode.type, selectedNode.data.category);
const statusColor = getStatusColor(selectedNode.data.status);
const isHost = selectedNode.type.startsWith('host_') || selectedNode.type.startsWith('vm_');
return (
<aside className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col" aria-label="Node details panel">
<div className="h-12 px-4 flex items-center justify-between border-b border-slate-700">
<h2 className="text-sm font-semibold text-white truncate">{selectedNode.name}</h2>
<div className="flex items-center gap-1">
{isHost && selectedNodeId && (
<>
<button
onClick={() => setFileBrowserOpen(true)}
className="p-1 hover:bg-slate-700 rounded transition-colors"
aria-label="Browse files"
>
<Folder className="w-4 h-4 text-slate-400" aria-hidden="true" />
</button>
<button
onClick={() => openTerminal(selectedNodeId)}
className="p-1 hover:bg-slate-700 rounded transition-colors"
aria-label="Open terminal"
>
<Terminal className="w-4 h-4 text-slate-400" aria-hidden="true" />
</button>
</>
)}
<button
onClick={() => setSelectedNode(null)}
className="p-1 hover:bg-slate-700 rounded transition-colors"
aria-label="Close details panel"
>
<X className="w-4 h-4 text-slate-400" aria-hidden="true" />
</button>
</div>
</div>
<div className="flex border-b border-slate-700" role="tablist" aria-label="Node information tabs">
{tabs.map((tab, index) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`tabpanel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)}
className={`flex-1 flex items-center justify-center gap-1 py-3 text-xs transition-colors ${activeTab === tab.id
? 'text-indigo-400 border-b-2 border-indigo-400 bg-indigo-500/10'
: 'text-slate-400 hover:text-white'
}`}
>
<span aria-hidden="true">{tab.icon}</span>
<span className="visually-hidden">{tab.label}</span>
</button>
))}
</div>
<div
className="flex-1 overflow-y-auto p-4"
role="tabpanel"
id={`tabpanel-${activeTab}`}
aria-labelledby={`tab-${activeTab}`}
>
{activeTab === 'details' && <DetailsTab node={selectedNode} nodeColor={nodeColor} statusColor={statusColor} />}
{activeTab === 'config' && <ConfigTab node={selectedNode} />}
{activeTab === 'files' && <FilesTab node={selectedNode} />}
{activeTab === 'usage' && <UsageTab node={selectedNode} />}
{activeTab === 'importance' && <ImportanceTab node={selectedNode} />}
</div>
{fileBrowserOpen && selectedNodeId && (
<FileBrowser
host={selectedNodeId}
onClose={() => setFileBrowserOpen(false)}
/>
)}
</aside>
);
}
function DetailsTab({ node, nodeColor, statusColor }: { node: TopologyNode; nodeColor: string; statusColor: string }) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center"
style={{ backgroundColor: `${nodeColor}20` }}
>
<div style={{ color: nodeColor }} className="text-lg font-bold">
{node.name.charAt(0).toUpperCase()}
</div>
</div>
<div>
<div className="text-white font-medium">{node.type.replace(/_/g, ' ')}</div>
<div className="flex items-center gap-2 text-sm">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: statusColor }}
/>
<span className="text-slate-400 capitalize">{node.data.status}</span>
</div>
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1">IP Address</div>
<div className="font-mono text-sm text-white">{node.data.ip || 'N/A'}</div>
</div>
{node.data.description && (
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1">Description</div>
<div className="text-sm text-slate-300">{node.data.description}</div>
</div>
)}
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1">Metadata</div>
<div className="bg-slate-900 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto">
{JSON.stringify(node.data.metadata, null, 2)}
</div>
</div>
</div>
</div>
);
}
function ConfigTab({ node }: { node: TopologyNode }) {
const hasConfig = node.data.config;
if (!hasConfig) {
return (
<div className="text-center text-slate-500 py-8">
<FileCode className="w-8 h-8 mx-auto mb-2 opacity-50" aria-hidden="true" />
<div className="text-sm">No configuration available</div>
</div>
);
}
return (
<div>
<pre className="bg-slate-900 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto">
{node.data.config}
</pre>
</div>
);
}
function FilesTab({ node }: { node: TopologyNode }) {
const files = node.data.files || [
'/etc/docker-compose.yml',
'/etc/traefik/dynamic.yml',
'/var/log/container.log'
];
return (
<div className="space-y-1">
{files.map((file: string, idx: number) => (
<button
key={idx}
className="w-full px-3 py-2 flex items-center gap-2 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-left transition-colors"
>
<FolderOpen className="w-4 h-4 text-slate-400" aria-hidden="true" />
<span className="font-mono text-xs text-slate-300 truncate">{file}</span>
</button>
))}
</div>
);
}
function UsageTab({ node }: { node: TopologyNode }) {
const isService = node.type === 'service';
if (!isService) {
return (
<div className="text-center text-slate-500 py-8">
<BarChart3 className="w-8 h-8 mx-auto mb-2 opacity-50" aria-hidden="true" />
<div className="text-sm">Usage data available for services only</div>
</div>
);
}
return (
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-400">CPU</span>
<span className="text-white">12.4%</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden" role="progressbar" aria-valuenow={12.4} aria-valuemin={0} aria-valuemax={100} aria-label="CPU usage">
<div className="h-full w-[12.4%] bg-indigo-500 rounded-full" />
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-400">Memory</span>
<span className="text-white">256 MB / 1 GB</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden" role="progressbar" aria-valuenow={25.6} aria-valuemin={0} aria-valuemax={100} aria-label="Memory usage">
<div className="h-full w-[25.6%] bg-purple-500 rounded-full" />
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-400">Network I/O</span>
<span className="text-white">1.2 MB/s 0.8 MB/s </span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden" role="progressbar" aria-valuenow={40} aria-valuemin={0} aria-valuemax={100} aria-label="Network I/O">
<div className="h-full w-[40%] bg-cyan-500 rounded-full" />
</div>
</div>
</div>
);
}
function ImportanceTab({ node }: { node: TopologyNode }) {
const importance = node.data.importance || 3;
const importanceLabel = getImportanceLabel(importance);
const importanceColor = getImportanceColor(importance);
const reasons: Record<number, string[]> = {
5: ['Critical infrastructure', 'Single point of failure', 'Required for other services'],
4: ['Important service', 'Used frequently', 'Difficult to replace'],
3: ['Standard service', 'Can be rebuilt', 'Not critical'],
2: ['Optional service', 'Rarely used', 'Easy to recreate'],
1: ['Development only', 'Non-critical', 'Can be disabled'],
};
return (
<div className="space-y-4">
<div className="flex items-center justify-center gap-2">
{[1, 2, 3, 4, 5].map(star => (
<Star
key={star}
className={`w-8 h-8 ${star <= importance ? 'fill-yellow-500 text-yellow-500' : 'text-slate-600'}`}
aria-hidden="true"
/>
))}
</div>
<div className="visually-hidden">Importance: {importance} out of 5 stars</div>
<div className="text-center">
<div className="text-lg font-semibold" style={{ color: importanceColor }}>
{importanceLabel}
</div>
<div className="text-sm text-slate-400">Importance Level {importance}/5</div>
</div>
<div className="bg-slate-700/50 rounded-lg p-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Why this level?</div>
<ul className="space-y-1">
{reasons[importance]?.map((reason, idx) => (
<li key={idx} className="text-sm text-slate-300 flex items-center gap-2">
<div className="w-1 h-1 bg-slate-500 rounded-full" aria-hidden="true" />
{reason}
</li>
))}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { AlertTriangle, X } from 'lucide-react';
import { useTopologyStore } from '../store/topologyStore';
export default function StaleWarning() {
const {
consecutiveFailures,
lastSuccessfulDiscovery,
staleWarningDismissed,
dismissStaleWarning
} = useTopologyStore();
if (consecutiveFailures < 3 || staleWarningDismissed) {
return null;
}
const formatTime = (date: Date | null) => {
if (!date) return 'Never';
return date.toLocaleString();
};
return (
<div className="bg-amber-900/30 border-b border-amber-700/50 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertTriangle className="w-4 h-4 text-amber-400 flex-shrink-0" />
<span className="text-amber-200 text-sm">
Data may be stale - Last successful discovery: {formatTime(lastSuccessfulDiscovery)}
</span>
<span className="text-amber-400/70 text-xs">
({consecutiveFailures} consecutive failures)
</span>
</div>
<button
onClick={dismissStaleWarning}
className="p-1 hover:bg-amber-800/50 rounded transition-colors"
title="Dismiss warning"
>
<X className="w-4 h-4 text-amber-400" />
</button>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { X } from 'lucide-react';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
interface TerminalProps {
host: string;
onClose: () => void;
}
export default function TerminalPanel({ host, onClose }: TerminalProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const terminalInstance = useRef<Terminal | null>(null);
const inputBuffer = useRef('');
const [currentPath, _setCurrentPath] = useState('~');
useEffect(() => {
if (!terminalRef.current) return;
const term = new Terminal({
theme: {
background: '#0f172a',
foreground: '#e2e8f0',
cursor: '#22d3ee',
cursorAccent: '#0f172a',
selectionBackground: '#334155',
},
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontSize: 13,
cursorBlink: true,
scrollback: 1000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(terminalRef.current);
fit.fit();
terminalInstance.current = term;
term.writeln('\x1b[36m╔════════════════════════════════════════════════╗\x1b[0m');
term.writeln('\x1b[36m║ Homelab Topology - SSH Terminal ║\x1b[0m');
term.writeln('\x1b[36m╚════════════════════════════════════════════════╝\x1b[0m');
term.writeln(`\x1b[32mConnecting to ${host}...\x1b[0m`);
term.writeln('');
const executeCommand = async (cmd: string) => {
try {
const response = await fetch(`${API_BASE_URL}/api/terminal/exec`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, command: cmd }),
});
const data = await response.json();
if (data.output) {
term.writeln(data.output);
}
if (data.error) {
term.writeln(`\x1b[31mError: ${data.error}\x1b[0m`);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error';
term.writeln(`\x1b[31mConnection error: ${msg}\x1b[0m`);
}
term.write(`\x1b[32m${host}:\x1b[0m${currentPath}$ `);
};
term.onData((data) => {
const code = data.charCodeAt(0);
if (code === 13) {
term.writeln('');
if (inputBuffer.current.trim()) {
executeCommand(inputBuffer.current);
} else {
term.write(`\x1b[32m${host}:\x1b[0m${currentPath}$ `);
}
inputBuffer.current = '';
} else if (code === 127) {
if (inputBuffer.current.length > 0) {
inputBuffer.current = inputBuffer.current.slice(0, -1);
term.write('\b \b');
}
} else if (code < 32) {
// skip control chars
} else {
inputBuffer.current += data;
term.write(data);
}
});
term.write(`\x1b[32m${host}:\x1b[0m${currentPath}$ `);
const handleResize = () => {
fit.fit();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
term.dispose();
};
}, [host, currentPath]);
return (
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
<div className="flex items-center gap-2">
<span className="text-cyan-400 font-mono">$</span>
<span className="text-slate-200 font-medium">{host}</span>
<span className="text-slate-500">:</span>
<span className="text-slate-400 font-mono">{currentPath}</span>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-slate-700 rounded transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div ref={terminalRef} className="flex-1 p-2" />
</div>
);
}

275
src/data/staticConfig.ts Normal file
View File

@@ -0,0 +1,275 @@
import { TopologyNode, TopologyEdge, NetworkInfo, Host, ServiceCategory } from '../types';
const serviceCategory: Record<string, ServiceCategory> = {
jellyfin: 'media',
immich: 'media',
sonarr: 'media',
radarr: 'media',
sabnzbd: 'media',
qbittorrent: 'media',
lidarr: 'media',
readarr: 'media',
bazarr: 'media',
tdarr: 'media',
traefik: 'infra',
authentik: 'infra',
vaultwarden: 'infra',
gitea: 'infra',
postgres: 'infra',
portainer: 'infra',
prometheus: 'monitoring',
grafana: 'monitoring',
loki: 'monitoring',
uptimekuma: 'monitoring',
cadvisor: 'monitoring',
nodeexporter: 'monitoring',
alertmanager: 'monitoring',
litellm: 'ai',
ollama: 'ai',
'codeserver-ai': 'ai',
qdrant: 'storage',
};
function getCategory(name: string): ServiceCategory {
const key = Object.keys(serviceCategory).find(k => name.toLowerCase().includes(k));
return serviceCategory[key || ''] || 'other';
}
export const staticNetworkInfo: NetworkInfo = {
gateway: {
model: 'UniFi Dream Machine Pro',
ip: '192.168.1.1'
},
vlans: [
{ id: 1, name: 'Default', subnet: '192.168.1.0/24', purpose: 'Core infrastructure' },
{ id: 3, name: 'Trusted', subnet: '192.168.3.0/24', purpose: 'Trusted devices' },
{ id: 10, name: 'Family', subnet: '192.168.10.0/24', purpose: 'Family devices' },
{ id: 20, name: 'Guest', subnet: '192.168.20.0/24', purpose: 'Guest network' },
{ id: 30, name: 'IoT', subnet: '192.168.30.0/24', purpose: 'IoT devices, Home Assistant' },
{ id: 50, name: 'Production', subnet: '192.168.50.0/24', purpose: 'Production services' }
],
wifi: [
{ ssid: 'Will of D.', vlan: 'default' },
{ ssid: 'Will of D. IoT', vlan: 30 },
{ ssid: 'Family of D.', vlan: 10 }
]
};
export const staticHosts: Host[] = [
{
name: 'ubuntu',
ip: '192.168.50.61',
type: 'vm',
role: 'Primary Docker Host',
containers: ['traefik', 'jellyfin', 'immich', 'authentik', 'gitea', 'prometheus', 'grafana', 'sonarr', 'radarr', 'sabnzbd', 'qbittorrent', 'lidarr', 'readarr', 'bazarr', 'tdarr', 'portainer', 'vaultwarden', 'loki', 'uptimekuma', 'cadvisor', 'nodeexporter', 'alertmanager', 'ollama', 'litellm', 'codeserver-ai', 'glance', 'gotify', 'prowlarr', 'jellyseerr', 'jellystat', 'jellysweep', 'navidrome', 'flaresolverr', 'gluetun', 'crowdsec', 'postgres-shared', 'immich_postgres', 'immich_redis', 'immich_server', 'immich_machine_learning', 'filebrowser', 'dockge', 'jfa-go', 'it-tools', 'bentopdf', 'maintainerr']
},
{
name: 'grizzley',
ip: '192.168.50.84',
type: 'rpi5',
role: 'Edge Services',
containers: ['traefik', 'frigate', 'scrypted', 'cloudflared']
},
{
name: 'ice',
ip: '192.168.50.197',
type: 'rpi5',
role: 'Spare/Development',
containers: []
},
{
name: 'panda',
ip: '192.168.30.196',
type: 'rpi5',
role: 'Home Assistant',
containers: []
},
{
name: 'truenas',
ip: '192.168.50.12',
type: 'physical',
role: 'Storage (NAS)',
containers: ['qdrant']
},
{
name: 'proxmox',
ip: '192.168.50.11',
type: 'physical',
role: 'Hypervisor',
containers: []
}
];
const containerDetails: Record<string, { description: string; ports?: string[]; importance: 1|2|3|4|5 }> = {
traefik: { description: 'Reverse proxy and load balancer', ports: ['80', '443'], importance: 5 },
jellyfin: { description: 'Media server', ports: ['8096', '9090'], importance: 5 },
immich: { description: 'Photo and video management', importance: 4 },
authentik: { description: 'Identity provider and SSO', importance: 5 },
gitea: { description: 'Self-hosted Git service', ports: ['3000', '2222'], importance: 4 },
prometheus: { description: 'Monitoring and metrics', ports: ['9090'], importance: 4 },
grafana: { description: 'Metrics visualization', ports: ['3000'], importance: 4 },
sonarr: { description: 'TV show management', importance: 4 },
radarr: { description: 'Movie management', importance: 4 },
tdarr: { description: 'Video transcoding', importance: 3 },
frigate: { description: 'NVR with local AI', importance: 4 },
vaultwarden: { description: 'Password manager', importance: 5 },
portainer: { description: 'Container management UI', ports: ['9000', '9443'], importance: 3 },
ollama: { description: 'Local LLM runtime', importance: 4 },
litellm: { description: 'LLM API gateway', ports: ['4000'], importance: 4 },
codeserver: { description: 'Browser-based VS Code', ports: ['8443'], importance: 3 },
};
function createNodesFromData(): TopologyNode[] {
const nodes: TopologyNode[] = [];
nodes.push({
id: 'gateway',
type: 'gateway',
name: 'UniFi Gateway',
data: {
status: 'running',
metadata: { model: 'UniFi Dream Machine Pro', ip: '192.168.1.1' },
importance: 5,
description: 'Main network gateway and firewall'
}
});
staticNetworkInfo.vlans.forEach((vlan) => {
nodes.push({
id: `vlan-${vlan.id}`,
type: 'vlan',
name: `VLAN ${vlan.id}: ${vlan.name}`,
data: {
status: 'running',
metadata: { subnet: vlan.subnet, purpose: vlan.purpose },
importance: 4,
description: vlan.purpose || '',
parentId: 'gateway'
}
});
});
staticNetworkInfo.wifi.forEach((wifi) => {
nodes.push({
id: `wifi-${wifi.ssid.replace(/\s+/g, '-')}`,
type: 'wifi',
name: wifi.ssid,
data: {
status: 'running',
metadata: { vlan: wifi.vlan },
importance: 3,
parentId: 'gateway'
}
});
});
const hostTypeMap: Record<string, 'host_physical' | 'host_vm' | 'host_container'> = {
physical: 'host_physical',
vm: 'host_vm',
rpi5: 'host_container',
container: 'host_container'
};
staticHosts.forEach((host) => {
const hostNode: TopologyNode = {
id: host.name,
type: hostTypeMap[host.type] || 'host_physical',
name: `${host.name} (${host.ip})`,
data: {
ip: host.ip,
status: 'running',
metadata: { role: host.role, type: host.type, containerCount: host.containers.length },
importance: host.role.includes('Primary') ? 5 : 4,
description: host.role,
parentId: 'vlan-50'
}
};
nodes.push(hostNode);
host.containers.forEach((container) => {
const details = containerDetails[container.replace(/-/g, '')] || { description: container, importance: 3 };
const portStr = details.ports ? details.ports.join(', ') : undefined;
nodes.push({
id: `${host.name}-${container}`,
type: 'service',
name: container,
data: {
status: 'running',
metadata: {
host: host.name,
image: `${container}:latest`,
ports: portStr
},
category: getCategory(container),
importance: details.importance,
description: details.description,
parentId: host.name
}
});
});
});
nodes.push({
id: 'truenas-nfs',
type: 'mount',
name: '/mnt/truenas/media',
data: {
status: 'running',
metadata: { type: 'nfs', server: '192.168.50.12' },
importance: 5,
description: 'TrueNAS NFS mount for media storage',
parentId: 'truenas'
}
});
['/movies', '/tv', '/music', '/photos'].forEach((path) => {
nodes.push({
id: `path-${path.replace(/\//g, '-')}`,
type: 'path',
name: path,
data: {
status: 'running',
metadata: { type: 'filesystem' },
importance: 4,
parentId: 'truenas-nfs'
}
});
});
return nodes;
}
function createEdgesFromData(): TopologyEdge[] {
const edges: TopologyEdge[] = [];
edges.push({ id: 'e-gateway-vlan50', source: 'gateway', target: 'vlan-50' });
staticNetworkInfo.vlans.forEach((vlan) => {
if (vlan.id !== 50) {
edges.push({ id: `e-gateway-vlan${vlan.id}`, source: 'gateway', target: `vlan-${vlan.id}` });
}
});
staticNetworkInfo.wifi.forEach((wifi) => {
edges.push({ id: `e-gateway-wifi-${wifi.ssid.replace(/\s+/g, '-')}`, source: 'gateway', target: `wifi-${wifi.ssid.replace(/\s+/g, '-')}` });
});
staticHosts.forEach((host) => {
edges.push({ id: `e-vlan50-${host.name}`, source: 'vlan-50', target: host.name });
host.containers.forEach((container) => {
edges.push({ id: `e-${host.name}-${container}`, source: host.name, target: `${host.name}-${container}` });
});
});
edges.push({ id: 'e-truenas-nfs', source: 'truenas', target: 'truenas-nfs' });
['/movies', '/tv', '/music', '/photos'].forEach((path) => {
edges.push({ id: `e-nfs-${path.replace(/\//g, '-')}`, source: 'truenas-nfs', target: `path-${path.replace(/\//g, '-')}` });
});
return edges;
}
export const initialNodes = createNodesFromData();
export const initialEdges = createEdgesFromData();

131
src/index.css Normal file
View File

@@ -0,0 +1,131 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-primary: #0F172A;
--bg-secondary: #1E293B;
--bg-tertiary: #334155;
--text-primary: #F8FAFC;
--text-secondary: #94A3B8;
--border: #475569;
--accent: #38BDF8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
}
#root {
width: 100vw;
height: 100vh;
}
/* ── Skip link (a11y) ────────────────────────────────────── */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--accent);
color: #000;
padding: 8px 16px;
z-index: 100;
font-weight: 600;
transition: top 200ms ease-out;
}
.skip-link:focus {
top: 0;
}
/* ── Focus-visible styles (a11y) ─────────────────────────── */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
button:focus-visible {
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.4);
outline: none;
}
/* ── Reduced motion (a11y) ───────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.react-flow__node {
transition: none !important;
}
}
/* ── React Flow overrides ────────────────────────────────── */
.react-flow__node {
cursor: pointer;
transition: transform 300ms ease-out;
}
.react-flow__edge-path {
stroke-width: 2;
}
.react-flow__minimap {
background-color: var(--bg-secondary);
}
/* Controls styling */
.react-flow__controls {
background: transparent;
box-shadow: none;
}
.react-flow__controls-button {
background-color: #334155;
border: 1px solid #475569;
color: #94A3B8;
border-bottom: 1px solid #475569;
}
.react-flow__controls-button:hover {
background-color: #475569;
color: #F8FAFC;
}
.react-flow__controls-button:active {
background-color: #1E293B;
}
.react-flow__controls-button svg {
fill: currentColor;
}
/* ── Visually hidden utility (a11y) ──────────────────────── */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

388
src/services/discovery.ts Normal file
View File

@@ -0,0 +1,388 @@
import { TopologyNode, TopologyEdge, Host, NetworkInfo, ServiceCategory, Status } from '../types';
const serviceCategory: Record<string, ServiceCategory> = {
jellyfin: 'media',
immich: 'media',
sonarr: 'media',
radarr: 'media',
sabnzbd: 'media',
qbittorrent: 'media',
lidarr: 'media',
readarr: 'media',
bazarr: 'media',
tdarr: 'media',
traefik: 'infra',
authentik: 'infra',
vaultwarden: 'infra',
gitea: 'infra',
postgres: 'infra',
portainer: 'infra',
prometheus: 'monitoring',
grafana: 'monitoring',
loki: 'monitoring',
uptimekuma: 'monitoring',
cadvisor: 'monitoring',
nodeexporter: 'monitoring',
litellm: 'ai',
ollama: 'ai',
'code-server': 'ai',
qdrant: 'storage',
};
function getCategory(name: string): ServiceCategory {
const key = Object.keys(serviceCategory).find(k =>
name.toLowerCase().includes(k.toLowerCase())
);
return serviceCategory[key || ''] || 'other';
}
export interface DiscoveredContainer {
name: string;
image: string;
status: string;
ports: string[];
created: string;
}
export interface DiscoveredHost {
name: string;
ip: string;
online: boolean;
containers: DiscoveredContainer[];
services?: string[];
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
cpuUsage?: number;
memoryUsage?: number;
uptime?: string;
}
export interface DiscoveryResult {
hosts: DiscoveredHost[];
timestamp: Date;
errors: string[];
}
export const defaultNetworkInfo: NetworkInfo = {
gateway: {
model: 'UniFi Dream Machine Pro',
ip: '192.168.1.1'
},
vlans: [
{ id: 1, name: 'Default', subnet: '192.168.1.0/24', purpose: 'Core infrastructure' },
{ id: 3, name: 'Trusted', subnet: '192.168.3.0/24', purpose: 'Trusted devices' },
{ id: 10, name: 'Family', subnet: '192.168.10.0/24', purpose: 'Family devices' },
{ id: 20, name: 'Guest', subnet: '192.168.20.0/24', purpose: 'Guest network' },
{ id: 30, name: 'IoT', subnet: '192.168.30.0/24', purpose: 'IoT devices, Home Assistant' },
{ id: 50, name: 'Production', subnet: '192.168.50.0/24', purpose: 'Production services' }
],
wifi: [
{ ssid: 'Will of D.', vlan: 'default' },
{ ssid: 'Will of D. IoT', vlan: 30 },
{ ssid: 'Family of D.', vlan: 10 }
]
};
export const defaultHosts: Host[] = [
{ name: 'ubuntu', ip: '192.168.50.61', type: 'vm', role: 'Primary Docker Host', containers: [] },
{ name: 'grizzley', ip: '192.168.50.84', type: 'rpi5', role: 'Edge Services', containers: [] },
{ name: 'ice', ip: '192.168.50.197', type: 'rpi5', role: 'Spare/Development', containers: [] },
{ name: 'panda', ip: '192.168.30.196', type: 'rpi5', role: 'Home Assistant', containers: [] },
{ name: 'truenas', ip: '192.168.50.12', type: 'physical', role: 'Storage (NAS)', containers: [] },
{ name: 'proxmox', ip: '192.168.50.11', type: 'physical', role: 'Hypervisor', containers: [] }
];
const containerDescriptions: Record<string, { description: string; importance: 1|2|3|4|5 }> = {
traefik: { description: 'Reverse proxy and load balancer', importance: 5 },
jellyfin: { description: 'Media server', importance: 5 },
immich: { description: 'Photo and video management', importance: 4 },
authentik: { description: 'Identity provider and SSO', importance: 5 },
gitea: { description: 'Self-hosted Git service', importance: 4 },
prometheus: { description: 'Monitoring and metrics', importance: 4 },
grafana: { description: 'Metrics visualization', importance: 4 },
sonarr: { description: 'TV show management', importance: 4 },
radarr: { description: 'Movie management', importance: 4 },
tdarr: { description: 'Video transcoding', importance: 3 },
frigate: { description: 'NVR with local AI', importance: 4 },
vaultwarden: { description: 'Password manager', importance: 5 },
portainer: { description: 'Container management UI', importance: 3 },
ollama: { description: 'Local LLM runtime', importance: 4 },
litellm: { description: 'LLM API gateway', importance: 4 },
};
function parseContainerStatus(status: string): Status {
const s = status.toLowerCase();
if (s.includes('up') || s.includes('running')) return 'running';
if (s.includes('exited') || s.includes('stopped') || s.includes('dead')) return 'stopped';
return 'unknown';
}
export function convertToTopology(
hosts: DiscoveredHost[],
networkInfo: NetworkInfo
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
const nodes: TopologyNode[] = [];
const edges: TopologyEdge[] = [];
nodes.push({
id: 'gateway',
type: 'gateway',
name: `UniFi Gateway`,
data: {
status: 'running',
metadata: { model: networkInfo.gateway.model, ip: networkInfo.gateway.ip },
importance: 5,
description: 'Main network gateway and firewall'
}
});
networkInfo.vlans.forEach((vlan) => {
nodes.push({
id: `vlan-${vlan.id}`,
type: 'vlan',
name: `VLAN ${vlan.id}: ${vlan.name}`,
data: {
status: 'running',
metadata: { subnet: vlan.subnet, purpose: vlan.purpose },
importance: 4,
description: vlan.purpose || '',
parentId: 'gateway'
}
});
edges.push({ id: `e-gateway-vlan${vlan.id}`, source: 'gateway', target: `vlan-${vlan.id}` });
});
networkInfo.wifi.forEach((wifi) => {
const wifiId = `wifi-${wifi.ssid.replace(/\s+/g, '-')}`;
nodes.push({
id: wifiId,
type: 'wifi',
name: wifi.ssid,
data: {
status: 'running',
metadata: { vlan: wifi.vlan },
importance: 3,
parentId: 'gateway'
}
});
edges.push({ id: `e-gateway-${wifiId}`, source: 'gateway', target: wifiId });
});
hosts.forEach((host) => {
const parentVlan = host.ip.startsWith('192.168.50') ? 'vlan-50' :
host.ip.startsWith('192.168.30') ? 'vlan-30' :
host.ip.startsWith('192.168.10') ? 'vlan-10' : 'vlan-1';
const hostType = host.name === 'proxmox' || host.name === 'truenas' ? 'host_physical' :
host.name === 'ubuntu' ? 'host_vm' : 'host_container';
const hostNode: TopologyNode = {
id: host.name,
type: hostType,
name: `${host.name} (${host.ip})`,
data: {
ip: host.ip,
status: host.online ? 'running' : 'stopped',
metadata: {
role: host.name === 'ubuntu' ? 'Primary Docker Host' :
host.name === 'grizzley' ? 'Edge Services' :
host.name === 'truenas' ? 'Storage (NAS)' :
host.name === 'proxmox' ? 'Hypervisor' : 'Host',
type: hostType,
containerCount: host.containers.length,
cpuUsage: host.cpuUsage,
memoryUsage: host.memoryUsage,
uptime: host.uptime
},
importance: host.name === 'ubuntu' ? 5 : 4,
description: host.name === 'ubuntu' ? 'Primary Docker Host with GPU' :
host.name === 'grizzley' ? 'Edge Traefik & Camera Services' :
host.name === 'truenas' ? 'TrueNAS Storage' : 'Host',
parentId: parentVlan
}
};
nodes.push(hostNode);
edges.push({ id: `e-${parentVlan}-${host.name}`, source: parentVlan, target: host.name });
host.containers.forEach((container) => {
const details = containerDescriptions[container.name.replace(/-/g, '')] || { description: container.name, importance: 3 };
const portStr = container.ports.length > 0 ? container.ports.join(', ') : undefined;
nodes.push({
id: `${host.name}-${container.name}`,
type: 'service',
name: container.name,
data: {
status: parseContainerStatus(container.status),
metadata: {
host: host.name,
image: container.image,
ports: portStr,
created: container.created
},
category: getCategory(container.name),
importance: details.importance,
description: details.description,
parentId: host.name
}
});
edges.push({ id: `e-${host.name}-${container.name}`, source: host.name, target: `${host.name}-${container.name}` });
});
if (host.services) {
host.services.forEach((service) => {
nodes.push({
id: `${host.name}-service-${service}`,
type: 'systemd_service',
name: service,
data: {
status: 'running',
metadata: {
host: host.name,
type: 'systemd'
},
category: 'infra',
importance: 3,
description: `Systemd service: ${service}`,
parentId: host.name
}
});
edges.push({ id: `e-${host.name}-service-${service}`, source: host.name, target: `${host.name}-service-${service}` });
});
}
if (host.vms) {
host.vms.forEach((vm) => {
const vmType = vm.type === 'lxc' ? 'vm_lxc' : 'vm_qemu';
nodes.push({
id: `${host.name}-vm-${vm.id}`,
type: vmType,
name: vm.name,
data: {
status: vm.status === 'running' ? 'running' : 'stopped',
metadata: {
host: host.name,
vmId: vm.id,
type: vm.type
},
category: 'infra',
importance: 4,
description: `${vm.type.toUpperCase()} VM: ${vm.name}`,
parentId: host.name
}
});
edges.push({ id: `e-${host.name}-vm-${vm.id}`, source: host.name, target: `${host.name}-vm-${vm.id}` });
});
}
});
const truenasNode: TopologyNode = {
id: 'truenas-nfs',
type: 'mount',
name: '/mnt/truenas/media',
data: {
status: 'running',
metadata: { type: 'nfs', server: '192.168.50.12' },
importance: 5,
description: 'TrueNAS NFS mount for media storage',
parentId: 'truenas'
}
};
nodes.push(truenasNode);
edges.push({ id: 'e-truenas-nfs', source: 'truenas', target: 'truenas-nfs' });
['/movies', '/tv', '/music', '/photos'].forEach((path) => {
const pathId = `path-${path.replace(/\//g, '-')}`;
nodes.push({
id: pathId,
type: 'path',
name: path,
data: {
status: 'running',
metadata: { type: 'filesystem' },
importance: 4,
parentId: 'truenas-nfs'
}
});
edges.push({ id: `e-nfs-${pathId}`, source: 'truenas-nfs', target: pathId });
});
return { nodes, edges };
}
export async function discoverHosts(
hosts: string[],
_sshConfig?: string
): Promise<DiscoveryResult> {
const results: DiscoveredHost[] = [];
const errors: string[] = [];
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
const simulatedHosts: Record<string, () => DiscoveredHost> = {
'ubuntu': () => ({
name: 'ubuntu',
ip: '192.168.50.61',
online: true,
cpuUsage: Math.round(rand(5, 35) * 10) / 10,
memoryUsage: Math.round(rand(35, 65) * 10) / 10,
uptime: '45 days',
containers: [
{ name: 'traefik', image: 'traefik:v3.6.7', status: 'running', ports: ['80', '443'], created: '2024-01-15' },
{ name: 'jellyfin', image: 'jellyfin/jellyfin:10.11.5', status: 'running', ports: ['8096', '9090'], created: '2024-01-15' },
{ name: 'immich', image: 'ghcr.io/immich-app/immich-server:release', status: 'running', ports: [], created: '2024-06-20' },
{ name: 'authentik', image: 'ghcr.io/goauthentik/server:2025.2', status: 'running', ports: [], created: '2024-02-10' },
{ name: 'gitea', image: 'gitea/gitea:latest', status: 'running', ports: ['3000', '2222'], created: '2024-01-20' },
{ name: 'prometheus', image: 'prom/prometheus:latest', status: 'running', ports: ['9090'], created: '2024-01-15' },
{ name: 'grafana', image: 'grafana/grafana:11.4.0', status: 'running', ports: ['3000'], created: '2024-01-15' },
{ name: 'sonarr', image: 'lscr.io/linuxserver/sonarr:latest', status: 'running', ports: [], created: '2024-01-15' },
{ name: 'radarr', image: 'lscr.io/linuxserver/radarr:latest', status: 'running', ports: [], created: '2024-01-15' },
{ name: 'tdarr', image: 'ghcr.io/haveagitgat/tdarr:latest', status: 'running', ports: ['8265', '8266', '8267'], created: '2024-01-15' },
]
}),
'grizzley': () => ({
name: 'grizzley',
ip: '192.168.50.84',
online: true,
cpuUsage: Math.round(rand(3, 25) * 10) / 10,
memoryUsage: Math.round(rand(45, 80) * 10) / 10,
uptime: '30 days',
containers: [
{ name: 'traefik', image: 'traefik:v3.6.7', status: 'running', ports: ['80', '443'], created: '2024-01-10' },
{ name: 'frigate', image: 'ghcr.io/blakeblackscreen/frigate:0.14', status: 'running', ports: ['5000', '8554'], created: '2024-03-01' },
{ name: 'scrypted', image: 'koush/scrypted', status: 'running', ports: ['10443'], created: '2024-04-15' },
]
}),
'truenas': () => ({
name: 'truenas',
ip: '192.168.50.12',
online: true,
cpuUsage: Math.round(rand(1, 15) * 10) / 10,
memoryUsage: Math.round(rand(20, 50) * 10) / 10,
uptime: '90 days',
containers: [
{ name: 'qdrant', image: 'qdrant/qdrant:v1.12.0', status: 'running', ports: ['6333', '6334'], created: '2024-02-01' }
]
})
};
for (const hostName of hosts) {
const hostFactory = simulatedHosts[hostName];
if (hostFactory) {
results.push(hostFactory());
} else {
results.push({
name: hostName,
ip: '',
online: false,
containers: []
});
errors.push(`Host ${hostName}: No data available (use SSH to discover)`);
}
}
return {
hosts: results,
timestamp: new Date(),
errors
};
}

View File

@@ -0,0 +1,133 @@
import { Client } from 'ssh2';
export interface SSHConnectionConfig {
host: string;
port?: number;
username: string;
privateKey?: string;
password?: string;
}
export interface DockerContainer {
name: string;
image: string;
status: string;
ports: string[];
created: string;
}
export interface HostInfo {
name: string;
ip: string;
online: boolean;
containers: DockerContainer[];
cpuUsage?: number;
memoryUsage?: number;
uptime?: string;
error?: string;
}
async function connectSSH(config: SSHConnectionConfig): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => resolve(conn));
conn.on('error', (err) => reject(err));
conn.connect({
host: config.host,
port: config.port || 22,
username: config.username,
privateKey: config.privateKey,
password: config.password,
readyTimeout: 10000,
});
});
}
async function execSSH(conn: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
let output = '';
stream.on('close', () => resolve(output));
stream.on('data', (data: Buffer) => { output += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
});
});
}
export async function discoverHostViaSSH(
hostName: string,
ip: string,
sshConfig?: SSHConnectionConfig
): Promise<HostInfo> {
const defaultConfig: SSHConnectionConfig = {
host: ip,
username: 'bear',
privateKey: require('fs').readFileSync(require('os').homedir() + '/.ssh/id_ed25519'),
};
const config = sshConfig || defaultConfig;
try {
const conn = await connectSSH(config);
const dockerPsCmd = `docker ps --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}|{{.CreatedAt}}'`;
const dockerPsOutput = await execSSH(conn, dockerPsCmd);
const containers: DockerContainer[] = dockerPsOutput
.trim()
.split('\n')
.filter(line => line.trim())
.map(line => {
const [name, image, status, ports, created] = line.split('|');
return {
name: name.trim(),
image: image.trim(),
status: status.trim(),
ports: ports.trim() ? ports.trim().split(', ') : [],
created: created.trim(),
};
});
conn.end();
return {
name: hostName,
ip: ip,
online: true,
containers,
};
} catch (error: any) {
return {
name: hostName,
ip: ip,
online: false,
containers: [],
error: error.message || 'Connection failed',
};
}
}
export async function discoverAllHostsSSH(
hosts: { name: string; ip: string }[]
): Promise<HostInfo[]> {
const results = await Promise.all(
hosts.map(host => discoverHostViaSSH(host.name, host.ip))
);
return results;
}
if (require.main === module) {
const hosts = [
{ name: 'ubuntu', ip: '192.168.50.61' },
{ name: 'grizzley', ip: '192.168.50.84' },
{ name: 'truenas', ip: '192.168.50.12' },
];
discoverAllHostsSSH(hosts).then(results => {
console.log(JSON.stringify(results, null, 2));
}).catch(console.error);
}

View File

@@ -0,0 +1,146 @@
import { describe, test, expect, afterEach } from 'vitest';
import { useTopologyStore } from './topologyStore';
import { TopologyNode } from '../types';
const mockNodes: TopologyNode[] = [
{
id: 'gateway-1',
name: 'Gateway',
type: 'gateway',
data: {
status: 'running',
ip: '10.0.0.1',
parentId: undefined,
importance: 5,
metadata: {},
},
},
{
id: 'host-1',
name: 'Ubuntu',
type: 'host_vm',
data: {
status: 'running',
ip: '10.0.0.10',
parentId: 'gateway-1',
importance: 4,
metadata: {},
},
},
{
id: 'service-1',
name: 'Traefik',
type: 'service',
data: {
status: 'running',
ip: '10.0.0.10',
parentId: 'host-1',
category: 'infra',
importance: 5,
metadata: {},
},
},
{
id: 'service-2',
name: 'Plex',
type: 'service',
data: {
status: 'stopped',
ip: '10.0.0.10',
parentId: 'host-1',
category: 'media',
importance: 2,
metadata: {},
},
},
];
describe('topologyStore', () => {
afterEach(() => {
// Reset store state between tests
useTopologyStore.setState({
nodes: [],
edges: [],
selectedNodeId: null,
searchQuery: '',
typeFilters: [],
statusFilter: 'all',
highlightPath: [],
});
});
/* ----------------------------------------------------------------
* Basic state management
* ------------------------------------------------------------- */
describe('setNodes', () => {
test('sets nodes correctly', () => {
useTopologyStore.getState().setNodes(mockNodes);
expect(useTopologyStore.getState().nodes).toHaveLength(4);
});
});
describe('setSelectedNode', () => {
test('selects a node by id', () => {
useTopologyStore.getState().setNodes(mockNodes);
useTopologyStore.getState().setSelectedNode('host-1');
expect(useTopologyStore.getState().selectedNodeId).toBe('host-1');
});
test('clears selection with null', () => {
useTopologyStore.getState().setSelectedNode('host-1');
useTopologyStore.getState().setSelectedNode(null);
expect(useTopologyStore.getState().selectedNodeId).toBeNull();
});
});
/* ----------------------------------------------------------------
* Filtering
* ------------------------------------------------------------- */
describe('getFilteredNodes', () => {
test('returns all nodes when no filters active', () => {
useTopologyStore.getState().setNodes(mockNodes);
const filtered = useTopologyStore.getState().getFilteredNodes();
expect(filtered).toHaveLength(4);
});
test('filters by search query', () => {
useTopologyStore.getState().setNodes(mockNodes);
useTopologyStore.getState().setSearchQuery('traefik');
const filtered = useTopologyStore.getState().getFilteredNodes();
expect(filtered.some(n => n.name === 'Traefik')).toBe(true);
});
test('filters by status', () => {
useTopologyStore.getState().setNodes(mockNodes);
useTopologyStore.getState().setStatusFilter('stopped');
const filtered = useTopologyStore.getState().getFilteredNodes();
expect(filtered.every(n => n.data.status === 'stopped')).toBe(true);
});
test('filters by type', () => {
useTopologyStore.getState().setNodes(mockNodes);
useTopologyStore.getState().toggleTypeFilter('service');
const filtered = useTopologyStore.getState().getFilteredNodes();
expect(filtered.every(n => n.type === 'service')).toBe(true);
});
});
/* ----------------------------------------------------------------
* Type filter toggle
* ------------------------------------------------------------- */
describe('toggleTypeFilter', () => {
test('adds type when not present', () => {
useTopologyStore.getState().toggleTypeFilter('gateway');
expect(useTopologyStore.getState().typeFilters).toContain('gateway');
});
test('removes type when already present', () => {
useTopologyStore.getState().toggleTypeFilter('gateway');
useTopologyStore.getState().toggleTypeFilter('gateway');
expect(useTopologyStore.getState().typeFilters).not.toContain('gateway');
});
});
});

246
src/store/topologyStore.ts Normal file
View File

@@ -0,0 +1,246 @@
import { create } from 'zustand';
import { persist, createJSONStorage, devtools } from 'zustand/middleware';
import { ViewMode, TopologyNode, TopologyEdge, NetworkInfo, Host, NodeType } from '../types';
export type Orientation = 'LR' | 'TB';
export type StatusFilter = 'all' | 'running' | 'stopped';
// All node types for default filter
const ALL_NODE_TYPES: NodeType[] = [
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container',
'vm_lxc', 'vm_qemu', 'systemd_service', 'service', 'volume', 'mount', 'path'
];
interface TopologyState {
nodes: TopologyNode[];
edges: TopologyEdge[];
selectedNodeId: string | null;
viewMode: ViewMode;
orientation: Orientation;
searchQuery: string;
typeFilters: NodeType[];
statusFilter: StatusFilter;
leftPanelOpen: boolean;
rightPanelOpen: boolean;
lastUpdated: Date | null;
isLoading: boolean;
pollInterval: number;
networkInfo: NetworkInfo | null;
hosts: Host[];
dataSource: 'live' | 'simulated';
consecutiveFailures: number;
lastSuccessfulDiscovery: Date | null;
commandPaletteOpen: boolean;
terminalOpen: boolean;
terminalHost: string | null;
highlightPath: string[];
connectionStatus: 'ws' | 'polling' | 'disconnected';
staleWarningDismissed: boolean;
setConnectionStatus: (status: 'ws' | 'polling' | 'disconnected') => void;
setNodes: (nodes: TopologyNode[]) => void;
setEdges: (edges: TopologyEdge[]) => void;
setSelectedNode: (nodeId: string | null) => void;
setViewMode: (mode: ViewMode) => void;
setOrientation: (orientation: Orientation) => void;
setSearchQuery: (query: string) => void;
toggleTypeFilter: (type: NodeType) => void;
setStatusFilter: (filter: StatusFilter) => void;
toggleLeftPanel: () => void;
toggleRightPanel: () => void;
setLastUpdated: (date: Date) => void;
openTerminal: (host: string) => void;
closeTerminal: () => void;
setIsLoading: (loading: boolean) => void;
setPollInterval: (interval: number) => void;
setNetworkInfo: (info: NetworkInfo) => void;
setHosts: (hosts: Host[]) => void;
setDataSource: (source: 'live' | 'simulated') => void;
incrementFailures: () => void;
resetFailures: () => void;
setLastSuccessfulDiscovery: (date: Date) => void;
toggleCommandPalette: () => void;
setHighlightPath: (ids: string[]) => void;
dismissStaleWarning: () => void;
getSelectedNode: () => TopologyNode | null;
getChildNodes: () => TopologyNode[];
getFilteredNodes: () => TopologyNode[];
}
export const useTopologyStore = create<TopologyState>()(
devtools(
persist(
(set, get) => ({
nodes: [],
edges: [],
selectedNodeId: null,
viewMode: 'full',
orientation: 'LR',
searchQuery: '',
typeFilters: ALL_NODE_TYPES,
statusFilter: 'all',
leftPanelOpen: true,
rightPanelOpen: true,
lastUpdated: null,
isLoading: false,
pollInterval: 30000,
networkInfo: null,
hosts: [],
dataSource: 'simulated',
consecutiveFailures: 0,
lastSuccessfulDiscovery: null,
commandPaletteOpen: false,
terminalOpen: false,
terminalHost: null,
highlightPath: [],
connectionStatus: 'polling',
staleWarningDismissed: false,
setNodes: (nodes) => set({ nodes }),
setEdges: (edges) => set({ edges }),
setSelectedNode: (nodeId) => {
if (!nodeId) {
set({ selectedNodeId: nodeId, highlightPath: [] });
return;
}
const state = get();
const path: string[] = [nodeId];
let currentNode = state.nodes.find(n => n.id === nodeId);
while (currentNode?.data?.parentId) {
path.push(currentNode.data.parentId);
currentNode = state.nodes.find(n => n.id === currentNode?.data?.parentId);
}
set({ selectedNodeId: nodeId, highlightPath: path });
},
setViewMode: (mode) => set({ viewMode: mode }),
setOrientation: (orientation) => set({ orientation }),
setSearchQuery: (query) => set({ searchQuery: query }),
toggleTypeFilter: (type) => set((state) => {
const exists = state.typeFilters.includes(type);
return {
typeFilters: exists
? state.typeFilters.filter(t => t !== type)
: [...state.typeFilters, type]
};
}),
setStatusFilter: (filter) => set({ statusFilter: filter }),
toggleLeftPanel: () => set((state) => ({ leftPanelOpen: !state.leftPanelOpen })),
toggleRightPanel: () => set((state) => ({ rightPanelOpen: !state.rightPanelOpen })),
setLastUpdated: (date) => set({ lastUpdated: date }),
setIsLoading: (loading) => set({ isLoading: loading }),
setPollInterval: (interval) => set({ pollInterval: interval }),
setNetworkInfo: (info) => set({ networkInfo: info }),
setHosts: (hosts) => set({ hosts }),
setDataSource: (source) => set({ dataSource: source }),
incrementFailures: () => set((state) => ({ consecutiveFailures: state.consecutiveFailures + 1 })),
resetFailures: () => set({ consecutiveFailures: 0 }),
setLastSuccessfulDiscovery: (date) => set({ lastSuccessfulDiscovery: date }),
toggleCommandPalette: () => set((state) => ({ commandPaletteOpen: !state.commandPaletteOpen })),
setHighlightPath: (ids) => set({ highlightPath: ids }),
setConnectionStatus: (status) => set({ connectionStatus: status }),
dismissStaleWarning: () => set({ staleWarningDismissed: true }),
openTerminal: (host) => set({ terminalOpen: true, terminalHost: host }),
closeTerminal: () => set({ terminalOpen: false, terminalHost: null }),
getSelectedNode: () => {
const { nodes, selectedNodeId } = get();
return nodes.find(n => n.id === selectedNodeId) || null;
},
getChildNodes: () => {
const { nodes, selectedNodeId } = get();
if (!selectedNodeId) return [];
const selectedNode = nodes.find(n => n.id === selectedNodeId);
if (!selectedNode) return [];
return nodes.filter(n => n.data.parentId === selectedNodeId);
},
getFilteredNodes: () => {
const { nodes, viewMode, searchQuery, typeFilters, statusFilter } = get();
let filtered = nodes;
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(n =>
n.name.toLowerCase().includes(query) ||
n.data.ip?.toLowerCase().includes(query)
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(n => n.data.status === statusFilter);
}
if (typeFilters.length > 0 && typeFilters.length < ALL_NODE_TYPES.length) {
filtered = filtered.filter(n => typeFilters.includes(n.type));
}
let allowedTypes: NodeType[] = [];
if (viewMode === 'network') {
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
} else if (viewMode === 'host') {
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
} else if (viewMode === 'service') {
allowedTypes = ['host_physical', 'host_vm', 'host_container', 'service', 'volume'];
} else if (viewMode === 'filesystem') {
allowedTypes = ['volume', 'mount', 'path'];
}
if (allowedTypes.length > 0) {
const nodeMap = new Map(nodes.map(n => [n.id, n]));
const includeSet = new Set<string>();
nodes.forEach(node => {
if (allowedTypes.includes(node.type)) {
includeSet.add(node.id);
let current: TopologyNode | undefined = node;
while (current?.data?.parentId) {
const parentId = current.data.parentId;
includeSet.add(parentId);
current = nodeMap.get(parentId);
if (!current) break;
}
}
});
filtered = filtered.filter(n => includeSet.has(n.id));
}
return filtered;
}
}),
{
name: 'homelab-topology-settings',
version: 2,
storage: createJSONStorage(() => localStorage),
partialize: (state: TopologyState) => ({
viewMode: state.viewMode,
orientation: state.orientation,
searchQuery: state.searchQuery,
typeFilters: state.typeFilters,
statusFilter: state.statusFilter,
leftPanelOpen: state.leftPanelOpen,
rightPanelOpen: state.rightPanelOpen,
pollInterval: state.pollInterval
})
}), { name: 'TopologyStore' }));
// Focused selector hooks to avoid unnecessary re-renders
export const useSelectedNode = () => useTopologyStore((s) => {
const { nodes, selectedNodeId } = s;
return nodes.find(n => n.id === selectedNodeId) || null;
});
export const useChildNodes = () => useTopologyStore((s) => {
const { nodes, selectedNodeId } = s;
if (!selectedNodeId) return [];
return nodes.filter(n => n.data.parentId === selectedNodeId);
});
export const useFilteredNodes = () => useTopologyStore((s) => s.getFilteredNodes());

140
src/types/index.ts Normal file
View File

@@ -0,0 +1,140 @@
export type NodeType =
| 'gateway'
| 'vlan'
| 'wifi'
| 'host_physical'
| 'host_vm'
| 'host_container'
| 'vm_lxc'
| 'vm_qemu'
| 'systemd_service'
| 'service'
| 'volume'
| 'mount'
| 'path';
export type ServiceCategory = 'media' | 'infra' | 'monitoring' | 'ai' | 'storage' | 'other';
export type Status = 'running' | 'stopped' | 'unknown';
export type ViewMode = 'network' | 'host' | 'service' | 'filesystem' | 'full';
export interface NodeData {
ip?: string;
mac?: string;
status: Status;
metadata: Record<string, unknown>;
config?: string;
files?: string[];
importance: 1 | 2 | 3 | 4 | 5;
category?: ServiceCategory;
description?: string;
parentId?: string;
children?: string[];
}
export interface TopologyNode {
id: string;
type: NodeType;
name: string;
data: NodeData;
}
export interface TopologyEdge {
id: string;
source: string;
target: string;
label?: string;
}
export interface Host {
name: string;
ip: string;
type: 'physical' | 'vm' | 'container' | 'rpi5';
role: string;
containers: string[];
services?: string[];
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
}
export interface VLAN {
id: number;
name: string;
subnet: string;
purpose?: string;
}
export interface WifiNetwork {
ssid: string;
vlan: string | number;
}
export interface NetworkInfo {
gateway: {
model: string;
ip: string;
};
vlans: VLAN[];
wifi: WifiNetwork[];
}
export interface ServiceConfig {
name: string;
image: string;
ports?: string[];
volumes?: string[];
environment?: Record<string, string>;
}
// --- Proxmox Admin types (proxmox-admin skill) ---
export interface ProxmoxVM {
vmid: number;
name: string;
status: 'running' | 'stopped' | 'paused';
type: 'qemu' | 'lxc';
cpu: number;
mem: number;
maxmem: number;
disk: number;
maxdisk: number;
uptime: number;
node: string;
}
export interface ProxmoxContainer {
vmid: number;
name: string;
status: 'running' | 'stopped';
type: 'lxc';
cpu: number;
mem: number;
maxmem: number;
disk: number;
maxdisk: number;
uptime: number;
node: string;
}
// --- Network Engineer types (network-engineer skill) ---
export interface NetworkSegment {
id: string;
name: string;
vlanId?: number;
subnet: string;
gateway?: string;
purpose: string;
hostCount: number;
}
// --- Infrastructure Monitoring types (infrastructure-monitoring skill) ---
export interface DiscoveryMetrics {
duration: number; // ms
hostCount: number;
successCount: number;
errorCount: number;
timestamp: string;
}

67
src/utils/colors.test.ts Normal file
View File

@@ -0,0 +1,67 @@
import { describe, test, expect } from 'vitest';
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from './colors';
describe('getNodeColor', () => {
test('returns correct color for gateway', () => {
expect(getNodeColor('gateway')).toBe('#6366F1');
});
test('returns correct color for physical host', () => {
expect(getNodeColor('host_physical')).toBe('#10B981');
});
test('returns fallback color for unknown type', () => {
expect(getNodeColor('nonexistent' as any)).toBe('#6B7280');
});
test('returns service category color when type is service', () => {
expect(getNodeColor('service', 'media')).toBe('#EF4444');
expect(getNodeColor('service', 'infra')).toBe('#3B82F6');
expect(getNodeColor('service', 'monitoring')).toBe('#22C55E');
});
test('returns "other" service color when no category provided', () => {
expect(getNodeColor('service')).toBe('#6B7280');
});
});
describe('getStatusColor', () => {
test('returns green for running', () => {
expect(getStatusColor('running')).toBe('#22C55E');
});
test('returns red for stopped', () => {
expect(getStatusColor('stopped')).toBe('#EF4444');
});
test('returns gray for unknown', () => {
expect(getStatusColor('unknown')).toBe('#6B7280');
});
});
describe('getImportanceLabel', () => {
test('returns correct labels for all levels', () => {
expect(getImportanceLabel(1)).toBe('Minimal');
expect(getImportanceLabel(2)).toBe('Low');
expect(getImportanceLabel(3)).toBe('Medium');
expect(getImportanceLabel(4)).toBe('High');
expect(getImportanceLabel(5)).toBe('Critical');
});
test('returns Unknown for out-of-range values', () => {
expect(getImportanceLabel(0)).toBe('Unknown');
expect(getImportanceLabel(6)).toBe('Unknown');
});
});
describe('getImportanceColor', () => {
test('returns correct colors for all levels', () => {
expect(getImportanceColor(1)).toBe('#6B7280');
expect(getImportanceColor(3)).toBe('#F59E0B');
expect(getImportanceColor(5)).toBe('#EF4444');
});
test('returns fallback for out-of-range', () => {
expect(getImportanceColor(99)).toBe('#6B7280');
});
});

65
src/utils/colors.ts Normal file
View File

@@ -0,0 +1,65 @@
import { NodeType, ServiceCategory, Status } from '../types';
export function getNodeColor(type: NodeType, category?: ServiceCategory): string {
const colors: Record<NodeType, string> = {
gateway: '#6366F1',
vlan: '#8B5CF6',
wifi: '#EC4899',
host_physical: '#10B981',
host_vm: '#14B8A6',
host_container: '#F59E0B',
vm_lxc: '#22D3EE',
vm_qemu: '#06B6D4',
systemd_service: '#A78BFA',
service: getServiceColor(category),
volume: '#A855F7',
mount: '#84CC16',
path: '#EAB308',
};
return colors[type] || '#6B7280';
}
function getServiceColor(category?: ServiceCategory): string {
const categoryColors: Record<ServiceCategory, string> = {
media: '#EF4444',
infra: '#3B82F6',
monitoring: '#22C55E',
ai: '#F97316',
storage: '#06B6D4',
other: '#6B7280',
};
return categoryColors[category || 'other'];
}
export function getStatusColor(status: Status): string {
const colors: Record<Status, string> = {
running: '#22C55E',
stopped: '#EF4444',
unknown: '#6B7280',
};
return colors[status];
}
export function getImportanceLabel(importance: number): string {
const labels: Record<number, string> = {
1: 'Minimal',
2: 'Low',
3: 'Medium',
4: 'High',
5: 'Critical',
};
return labels[importance] || 'Unknown';
}
export function getImportanceColor(importance: number): string {
const colors: Record<number, string> = {
1: '#6B7280',
2: '#9CA3AF',
3: '#F59E0B',
4: '#F97316',
5: '#EF4444',
};
return colors[importance] || '#6B7280';
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

56
tailwind.config.js Normal file
View File

@@ -0,0 +1,56 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
network: {
gateway: '#6366F1',
vlan: '#8B5CF6',
wifi: '#EC4899',
},
host: {
physical: '#10B981',
vm: '#14B8A6',
container: '#F59E0B',
},
service: {
media: '#EF4444',
infra: '#3B82F6',
monitoring: '#22C55E',
ai: '#F97316',
storage: '#06B6D4',
},
filesystem: {
nfs: '#84CC16',
volume: '#A855F7',
path: '#EAB308',
},
},
fontFamily: {
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
sans: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'glow': 'glow 2s ease-in-out infinite alternate',
'scan': 'scan 3s linear infinite',
},
keyframes: {
glow: {
'0%': { boxShadow: '0 0 5px currentColor' },
'100%': { boxShadow: '0 0 20px currentColor, 0 0 40px currentColor' },
},
scan: {
'0%': { opacity: '0.3' },
'50%': { opacity: '0.6' },
'100%': { opacity: '0.3' },
},
},
},
},
plugins: [],
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

1
tsconfig.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/CommandPalette.tsx","./src/components/FileBrowser.tsx","./src/components/Header.tsx","./src/components/LeftPanel.tsx","./src/components/RightPanel.tsx","./src/components/StaleWarning.tsx","./src/components/TerminalPanel.tsx","./src/components/Dashboard/HostChart.tsx","./src/components/Dashboard/MetricsBar.tsx","./src/components/Graph/TopologyGraph.tsx","./src/data/staticConfig.ts","./src/services/discovery.ts","./src/services/sshDiscovery.ts","./src/store/topologyStore.test.ts","./src/store/topologyStore.ts","./src/types/index.ts","./src/utils/colors.test.ts","./src/utils/colors.ts"],"version":"5.6.3"}

2
vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

27
vite.config.js Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true,
port: 3000
},
build: {
target: 'esnext',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'graph-vendor': ['@xyflow/react', 'dagre'],
'ui-vendor': ['lucide-react', 'recharts'],
},
},
},
},
});

28
vite.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true,
port: 3000
},
build: {
target: 'esnext',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'graph-vendor': ['@xyflow/react', 'dagre'],
'ui-vendor': ['lucide-react', 'recharts'],
},
},
},
},
})

19
vitest.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['**/*.test.{ts,tsx}'],
exclude: ['**/node_modules/**', '**/e2e/**'],
alias: {
'@': resolve(__dirname, './src'),
},
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],
exclude: ['**/*.test.ts', '**/__mocks__/**', '**/node_modules/**'],
},
},
});