Compare commits

...

7 Commits

Author SHA1 Message Date
0910c966a5 feat: migrate from static config to database and add authentication
- Replaced static hosts.json and staticConfig.ts with SQLite database (Prisma)

- Implemented JWT authentication and Login UI

- Added dynamic API routes for hosts, topology, and settings

- Updated UI components to fetch and manage state dynamically

- Added Settings interface for managing hosts and topology nodes
2026-02-25 14:07:11 -08:00
df02542c26 fix(ui): optimize react state, performance, and ux logic
- Refactored useFilteredNodes to memoize logic and prevent re-rendering.
- Optimized LeftPanel useMemo dependencies and HostChart O(N) traversal.
- Fixed polling interval desync and added WebSocket throttling/React Strict Mode transport patch.
- Fixed Graph layout dependencies and 'ghost' children chevrons on collapsed nodes.
- Fixed RightPanel tab persistence UI bug and resolved React Hooks order crash.

No remaining known issues from the UI analysis.
2026-02-23 15:20:25 -08:00
d40be883fe Merge master into main 2026-02-20 20:39:36 -08:00
1f7d55818e docs: add comprehensive README 2026-02-20 20:38:27 -08:00
6dd679b8e0 feat: integrate all 10 skills into homelab-topology
- Added api-security-hardening (helmet, rate limits)
- Added nodejs-backend-patterns (error handling)
- Added observability-monitoring (pino logging)
- Added websocket-engineer (socket.io real-time updates)
- Added docker (Multi-stage build, compose)
- Added vitest (testing configuration and store tests)
- Added data-visualizer (MetricsBar and HostChart)
- Added infrastructure-monitoring/proxmox-admin/network-engineer types
- Fixed UI accessibility and styling
- Cleaned up node_modules tracking
2026-02-20 20:35:08 -08:00
3dc5d236a2 feat: expand discovery with systemd services, LXC/VMs, SSH terminal, and filebrowser
- Add systemd service discovery to backend
- Add Proxmox LXC/VM detection
- Add hostType field to config for better host categorization
- Fix SSH trust between hosts (ubuntu/grizzley -> truenas/proxmox)
- Add SSH terminal support via xterm.js
- Add filebrowser for browsing host filesystems
- Update frontend types and components for new node types
2026-02-20 17:18:33 -08:00
a4cff9894c feat(ui): add type filter toggles 2026-02-18 23:13:28 -08:00
75 changed files with 17997 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

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
node_modules/
dist/
dist-ssr/
*.local
.env
.env.*
!.env.example
*.log
*.sqlite
*.db
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-BAJgTfdz.js"></script>
<link rel="modulepreload" crossorigin href="/assets/graph-vendor-pGkIx_vZ.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BSwt2l3v.js">
<link rel="stylesheet" crossorigin href="/assets/index-CYGIQDBj.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

70
docker-compose.yml Normal file
View File

@@ -0,0 +1,70 @@
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
db:
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
db:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_USER: homelab
POSTGRES_PASSWORD: homelab_password
POSTGRES_DB: homelab_topology
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U homelab -d homelab_topology"]
interval: 5s
timeout: 5s
retries: 5
networks:
default:
name: homelab-topology
volumes:
pgdata:

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>

8315
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View File

@@ -0,0 +1,73 @@
{
"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",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"dagre": "^0.8.5",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"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",
"@prisma/client": "^5.22.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcrypt": "^6.0.0",
"@types/express-rate-limit": "^5.1.3",
"@types/jsdom": "^27.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@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",
"prisma": "^5.22.0",
"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: {},
},
}

View File

@@ -0,0 +1,88 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HostConfig" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ip" TEXT NOT NULL,
"sshUser" TEXT NOT NULL DEFAULT 'bear',
"sshKeyPath" TEXT,
"sshPort" INTEGER NOT NULL DEFAULT 22,
"hostType" TEXT NOT NULL DEFAULT 'docker-host',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HostConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NetworkNode" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ip" TEXT,
"status" TEXT NOT NULL DEFAULT 'unknown',
"importance" INTEGER NOT NULL DEFAULT 3,
"description" TEXT,
"metadata" JSONB,
"category" TEXT,
"parentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NetworkNode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NetworkEdge" (
"id" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"type" TEXT,
"label" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NetworkEdge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Settings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "HostConfig_name_key" ON "HostConfig"("name");
-- CreateIndex
CREATE UNIQUE INDEX "NetworkEdge_sourceId_targetId_key" ON "NetworkEdge"("sourceId", "targetId");
-- CreateIndex
CREATE UNIQUE INDEX "Settings_key_key" ON "Settings"("key");
-- AddForeignKey
ALTER TABLE "NetworkNode" ADD CONSTRAINT "NetworkNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "NetworkNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NetworkEdge" ADD CONSTRAINT "NetworkEdge_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "NetworkNode"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NetworkEdge" ADD CONSTRAINT "NetworkEdge_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "NetworkNode"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

75
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,75 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
username String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HostConfig {
id String @id @default(uuid())
name String @unique
ip String
sshUser String @default("bear")
sshKeyPath String?
sshPort Int @default(22)
hostType String @default("docker-host")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NetworkNode {
id String @id
type String
name String
ip String?
status String @default("unknown")
importance Int @default(3)
description String?
metadata Json?
category String? // for services
parentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parent NetworkNode? @relation("NodeHierarchy", fields: [parentId], references: [id])
children NetworkNode[] @relation("NodeHierarchy")
sourceEdges NetworkEdge[] @relation("EdgeSource")
targetEdges NetworkEdge[] @relation("EdgeTarget")
}
model NetworkEdge {
id String @id @default(uuid())
sourceId String
targetId String
type String?
label String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
source NetworkNode @relation("EdgeSource", fields: [sourceId], references: [id])
target NetworkNode @relation("EdgeTarget", fields: [targetId], references: [id])
@@unique([sourceId, targetId])
}
model Settings {
id String @id @default("singleton")
key String @unique
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

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"
}
]
}

31
server/config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { homedir } from 'os';
import { PrismaClient } from '@prisma/client';
import { HostConfig } from './types';
const prisma = new PrismaClient();
export async function getHostConfigs(): Promise<HostConfig[]> {
try {
const dbConfigs = await prisma.hostConfig.findMany();
return dbConfigs.map(h => ({
name: h.name,
ip: h.ip,
sshUser: h.sshUser || 'bear',
sshKeyPath: h.sshKeyPath?.replace(/^~/, homedir()),
sshPort: h.sshPort || 22,
hostType: h.hostType,
}));
} catch (error) {
console.error('Error fetching host configs from DB:', error);
return [];
}
}
export async function hasConfig(): Promise<boolean> {
try {
const count = await prisma.hostConfig.count();
return count > 0;
} catch (error) {
return false;
}
}

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);
}
}

119
server/index.ts Normal file
View File

@@ -0,0 +1,119 @@
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 authRouter from './routes/auth';
import topologyRouter from './routes/topology';
import hostsRouter from './routes/hosts';
import { requireAuth } from './middleware/auth';
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;
const allowedOrigins = ['http://localhost:3000', 'http://localhost:4173'];
const corsOrigin = process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN, ...allowedOrigins] : allowedOrigins;
// --- Socket.IO setup (websocket-engineer skill) ---
const io = new Server(httpServer, {
cors: {
origin: corsOrigin,
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: corsOrigin,
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', async (_req, res) => {
const hosts = await getHostConfigs();
res.json({ hosts });
});
}
// --- Public Routes ---
app.use('/api', authRouter);
// --- Protected Routes ---
app.use('/api', requireAuth, discoverRouter);
app.use('/api', requireAuth, configRouter);
app.use('/api', requireAuth, statsRouter);
app.use('/api', requireAuth, filesRouter);
app.use('/api', requireAuth, terminalRouter);
app.use('/api', requireAuth, topologyRouter);
app.use('/api', requireAuth, hostsRouter);
// --- Global error handler (must be last) ---
app.use(errorHandler);
// --- Start server ---
httpServer.listen(PORT, () => {
logger.info(`Server running on http://localhost:${PORT}`);
});

27
server/middleware/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthRequest extends Request {
user?: {
id: string;
username: string;
};
}
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-for-development-only';
export const requireAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ status: 'error', message: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string; username: string };
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ status: 'error', message: 'Invalid or expired token' });
}
};

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

78
server/routes/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import rateLimit from 'express-rate-limit';
const router = Router();
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-for-development-only';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 failed login attempts per window
message: { status: 'error', message: 'Too many login attempts, please try again later' },
skipSuccessfulRequests: true,
});
// Check if setup is required (no users exist)
router.get('/auth/status', async (req, res) => {
try {
const userCount = await prisma.user.count();
res.json({ setupRequired: userCount === 0 });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Database error' });
}
});
// Initial Setup (Only works if no users exist)
router.post('/auth/setup', async (req, res) => {
try {
const userCount = await prisma.user.count();
if (userCount > 0) {
return res.status(403).json({ status: 'error', message: 'Setup has already been completed' });
}
const { username, password } = req.body;
if (!username || !password || password.length < 8) {
return res.status(400).json({ status: 'error', message: 'Invalid username or password must be at least 8 characters' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { username, password: hashedPassword },
});
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
res.json({ status: 'success', token, user: { id: user.id, username: user.username } });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Failed to create user' });
}
});
// Login
router.post('/auth/login', loginLimiter, async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ status: 'error', message: 'Username and password required' });
}
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return res.status(401).json({ status: 'error', message: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).json({ status: 'error', message: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
res.json({ status: 'success', token, user: { id: user.id, username: user.username } });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Login failed' });
}
});
export default router;

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 = await 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;

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

@@ -0,0 +1,218 @@
/**
* 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 { PrismaClient } from '@prisma/client';
import { getHostConfigs } from '../config';
import { DiscoveryResponse } from '../types';
import { io } from '../index';
const router = Router();
const prisma = new PrismaClient();
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 - Trigger discovery but return cached immediately
router.post('/discover', async (req, res) => {
try {
// 1. Fetch from cache immediately for fast loading
const cachedSetting = await prisma.settings.findUnique({ where: { key: 'last_discovery' } });
let previousState: DiscoveryResponse = {
hosts: [],
timestamp: new Date().toISOString(),
errors: [],
};
if (cachedSetting && cachedSetting.value) {
try {
previousState = JSON.parse(cachedSetting.value);
} catch (e) {
console.error('Failed to parse cached discovery state');
}
}
// 2. Return cached response to unblock the UI request
res.json(previousState);
// 3. Kick off background discovery
runBackgroundDiscovery();
} catch (error: any) {
res.status(500).json({
hosts: [],
timestamp: new Date().toISOString(),
errors: [error.message || 'Cache retrieval failed'],
});
}
});
async function runBackgroundDiscovery() {
try {
const hosts = await getHostConfigs();
if (hosts.length === 0) {
const response: DiscoveryResponse = {
hosts: [],
timestamp: new Date().toISOString(),
errors: ['No hosts configured'],
};
await cacheAndEmit(response);
return;
}
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,
};
await cacheAndEmit(response);
} catch (error: any) {
console.error('Background discovery failed:', error);
}
}
async function cacheAndEmit(response: DiscoveryResponse) {
try {
// Cache the standard response to the DB
await prisma.settings.upsert({
where: { key: 'last_discovery' },
update: { value: JSON.stringify(response) },
create: { key: 'last_discovery', value: JSON.stringify(response) },
});
// Broadcast the full update via Socket.IO
io.emit('topology:update', response);
} catch (err) {
console.error('Failed to cache and emit discovery results:', err);
}
}
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 = await 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 = await 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;

50
server/routes/hosts.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
// GET all host configurations
router.get('/hosts', async (req, res) => {
try {
const hosts = await prisma.hostConfig.findMany();
res.json({ status: 'success', hosts });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Failed to fetch host configs' });
}
});
// POST a new host configuration
router.post('/hosts', async (req, res) => {
try {
const host = await prisma.hostConfig.create({ data: req.body });
res.json({ status: 'success', host });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to create host config. Ensure name is unique.' });
}
});
// PUT (update) a host configuration
router.put('/hosts/:id', async (req, res) => {
try {
const host = await prisma.hostConfig.update({
where: { id: req.params.id },
data: req.body,
});
res.json({ status: 'success', host });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to update host config' });
}
});
// DELETE a host configuration
router.delete('/hosts/:id', async (req, res) => {
try {
await prisma.hostConfig.delete({ where: { id: req.params.id } });
res.json({ status: 'success' });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to delete host config' });
}
});
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 = await 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 = await 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 = await 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;

71
server/routes/topology.ts Normal file
View File

@@ -0,0 +1,71 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
// GET all nodes and edges
router.get('/topology', async (req, res) => {
try {
const nodes = await prisma.networkNode.findMany();
const edges = await prisma.networkEdge.findMany();
res.json({ status: 'success', nodes, edges });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Failed to fetch topology' });
}
});
// POST a new node
router.post('/topology/nodes', async (req, res) => {
try {
const node = await prisma.networkNode.create({ data: req.body });
res.json({ status: 'success', node });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to create node' });
}
});
// PUT (update) a node
router.put('/topology/nodes/:id', async (req, res) => {
try {
const node = await prisma.networkNode.update({
where: { id: req.params.id },
data: req.body,
});
res.json({ status: 'success', node });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to update node' });
}
});
// DELETE a node
router.delete('/topology/nodes/:id', async (req, res) => {
try {
await prisma.networkNode.delete({ where: { id: req.params.id } });
res.json({ status: 'success' });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to delete node' });
}
});
// POST a new edge
router.post('/topology/edges', async (req, res) => {
try {
const edge = await prisma.networkEdge.create({ data: req.body });
res.json({ status: 'success', edge });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to create edge' });
}
});
// DELETE an edge
router.delete('/topology/edges/:id', async (req, res) => {
try {
await prisma.networkEdge.delete({ where: { id: req.params.id } });
res.json({ status: 'success' });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to delete edge' });
}
});
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)

371
src/App.tsx Normal file
View File

@@ -0,0 +1,371 @@
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';
import Login from './components/Login';
import SettingsOverlay from './components/Settings/SettingsOverlay';
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 token = useTopologyStore((s) => s.token);
const setConnectionStatus = useTopologyStore((s) => s.setConnectionStatus);
const {
leftPanelOpen,
rightPanelOpen,
isLoading,
pollInterval,
terminalOpen,
terminalHost,
toggleLeftPanel,
toggleRightPanel,
} = useTopologyStore(useShallow((s) => ({
leftPanelOpen: s.leftPanelOpen,
rightPanelOpen: s.rightPanelOpen,
isLoading: s.isLoading,
pollInterval: s.pollInterval,
terminalOpen: s.terminalOpen,
terminalHost: s.terminalHost,
toggleLeftPanel: s.toggleLeftPanel,
toggleRightPanel: s.toggleRightPanel,
})));
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 (isBackgroundPoll = false) => {
if (isLoadingRef.current) return;
// Only set loading state if there are no existing nodes (initial fresh load)
// or if this is an explicit user refresh (not background poll).
const isInitialLoad = useTopologyStore.getState().nodes.length === 0;
if (isInitialLoad && !isBackgroundPoll) {
setIsLoading(true);
}
try {
const response = await fetch(`${API_BASE_URL}/api/discover`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${useTopologyStore.getState().token}`
}
});
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(() => {
const isInitialLoad = useTopologyStore.getState().nodes.length === 0;
loadData(!isInitialLoad); // Poll in background if we already have nodes
}, []);
useEffect(() => {
// Rely on pollInterval from store state instead of ref
const intervalId = setInterval(() => loadData(true), pollInterval);
return () => clearInterval(intervalId);
}, [loadData, pollInterval]);
// --- WebSocket connection (websocket-engineer skill) ---
useEffect(() => {
const socket: Socket = ioClient(API_BASE_URL, {
transports: ['polling', 'websocket'],
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
let lastWsUpdate = 0;
socket.on('topology:update', (data: ApiDiscoveryResponse) => {
// Throttle updates to max 1 per second to prevent UI freezes
const now = Date.now();
if (now - lastWsUpdate < 1000) return;
lastWsUpdate = now;
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]);
if (!token) {
return <Login />;
}
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 relative" role="main" id="main-content" tabIndex={-1}>
{leftPanelOpen && (
<>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm z-10 md:hidden animate-in fade-in duration-300"
onClick={toggleLeftPanel}
aria-hidden="true"
/>
<LeftPanel />
</>
)}
<div className="flex-1 relative z-0">
<TopologyGraph />
</div>
{rightPanelOpen && (
<>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm z-10 md:hidden animate-in fade-in duration-300"
onClick={toggleRightPanel}
aria-hidden="true"
/>
<RightPanel />
</>
)}
</div>
<Footer />
<CommandPalette />
<SettingsOverlay />
{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 | string | null) => {
if (!date) return 'Never';
const d = new Date(date);
return isNaN(d.getTime()) ? 'Never' : d.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,358 @@
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-md px-4"
onClick={handleBackdropClick}
>
<div className="w-full max-w-xl glass border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center gap-3 px-4 py-4 border-b border-white/10 bg-slate-900/40">
<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 font-medium font-mono tracking-tight"
/>
<button
onClick={toggleCommandPalette}
className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all duration-200 hover:scale-105 hover:rotate-90"
>
<X className="w-4 h-4" />
</button>
</div>
<div ref={listRef} className="max-h-[50vh] overflow-y-auto py-2 bg-slate-900/40 relative">
{filteredCommands.length === 0 ? (
<div className="px-4 py-8 text-center text-slate-400 font-medium">
No commands found
</div>
) : (
Object.entries(groupedCommands).map(([category, cmds]) => (
<div key={category} className="mb-2">
<div className="px-4 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-widest sticky top-0 bg-slate-900/80 backdrop-blur-md z-10 border-y border-white/5">
{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-3 text-left transition-all duration-200 ${isSelected
? 'bg-indigo-500/20 text-white shadow-[inset_4px_0_0_rgba(99,102,241,1)]'
: 'text-slate-300 hover:bg-white/5'
}`}
>
<span className={`flex-shrink-0 transition-colors ${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-3 border-t border-white/10 bg-slate-900/60 flex items-center gap-4 text-[10px] text-slate-400 uppercase tracking-wider font-bold">
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-slate-800 border border-slate-700 rounded text-slate-300 shadow-sm"></kbd>
Navigate
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-slate-800 border border-slate-700 rounded text-slate-300 shadow-sm">Enter</kbd>
Select
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-slate-800 border border-slate-700 rounded text-slate-300 shadow-sm">Esc</kbd>
Close
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
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(() => {
// Build a parent mapping of children counts in one O(N) pass
const hostChildrenCounts = new Map<string, { total: number, running: number, stopped: number }>();
const hosts: typeof nodes = [];
nodes.forEach(n => {
if (n.type === 'host_physical' || n.type === 'host_vm' || n.type === 'host_container') {
hosts.push(n);
if (!hostChildrenCounts.has(n.id)) {
hostChildrenCounts.set(n.id, { total: 0, running: 0, stopped: 0 });
}
}
if (n.data.parentId) {
const parentCounts = hostChildrenCounts.get(n.data.parentId) || { total: 0, running: 0, stopped: 0 };
parentCounts.total++;
if (n.data.status === 'running') parentCounts.running++;
else if (n.data.status === 'stopped') parentCounts.stopped++;
hostChildrenCounts.set(n.data.parentId, parentCounts);
}
});
return hosts
.map((host) => {
const counts = hostChildrenCounts.get(host.id) || { total: 0, running: 0, stopped: 0 };
return {
name: host.name,
total: counts.total,
running: counts.running,
stopped: counts.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 && !isNaN(new Date(lastUpdated).getTime()) && (
<div className="flex items-center gap-1.5" title="Last Discovery">
<Clock size={13} className="text-slate-500" />
<span className="text-slate-500">
{new Date(lastUpdated).toLocaleTimeString()}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { useState, useEffect } from 'react';
import { Folder, File, ArrowLeft, X, RefreshCw, Terminal as TerminalIcon } from 'lucide-react';
import { useTopologyStore } from '../store/topologyStore';
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/${host}?path=${encodeURIComponent(path)}`,
{ headers: { 'Authorization': `Bearer ${useTopologyStore.getState().token}` } }
);
const data = await response.json();
if (!response.ok) {
setError(data.error || `Failed to fetch files: ${response.status} ${response.statusText}`);
setFiles([]);
} else {
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-black/60 backdrop-blur-md flex items-center justify-center p-4">
<div className="w-full max-w-4xl h-[85vh] glass rounded-xl shadow-2xl border border-white/10 flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex flex-col sm:flex-row items-center justify-between px-4 py-3 bg-slate-900/60 border-b border-white/10 gap-3">
<div className="flex items-center gap-2 w-full sm:w-auto overflow-x-auto hide-scrollbar">
<span className="text-slate-400 font-bold">/</span>
<span className="text-cyan-400 font-bold tracking-tight whitespace-nowrap">{host}</span>
<span className="text-slate-500 font-bold">:</span>
<input
type="text"
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && navigateTo(e.currentTarget.value)}
className="bg-slate-900/50 border border-white/10 text-slate-200 px-3 py-1.5 rounded-lg text-sm font-mono focus:outline-none focus:ring-1 focus:ring-cyan-500/50 w-full sm:w-80 transition-all shadow-inner"
/>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => fetchFiles(currentPath)}
className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
title="Refresh"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all duration-200 hover:scale-105 hover:rotate-90"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-2 bg-slate-900/40">
{loading ? (
<div className="flex items-center justify-center h-full">
<RefreshCw className="w-6 h-6 text-cyan-500/50 animate-spin" />
</div>
) : error ? (
<div className="text-red-400 p-4 font-mono text-sm">{error}</div>
) : (
<div className="font-mono text-sm">
{currentPath !== '/' && (
<button
onClick={goUp}
className="flex items-center gap-3 w-full px-3 py-2 hover:bg-white/5 rounded-lg transition-colors group"
>
<ArrowLeft className="w-4 h-4 text-slate-400 group-hover:-translate-x-1 transition-transform" />
<span className="text-slate-400 font-bold">..</span>
</button>
)}
{files.map((file) => (
<button
key={file.path}
onClick={() => file.type === 'directory' && navigateTo(file.path)}
className={`flex items-center gap-3 w-full px-3 py-2 hover:bg-white/5 rounded-lg transition-colors ${file.type !== 'directory' ? 'cursor-default' : ''
}`}
>
{getFileIcon(file.type)}
<span className="text-slate-200 flex-1 text-left truncate pr-4">{file.name}</span>
<span className="text-slate-500 text-xs w-20 text-right">{formatSize(file.size)}</span>
<span className="text-slate-500 text-xs w-32 text-right hidden sm:block">{file.modified}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,297 @@
import { useCallback, useMemo, memo, useEffect } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
Node,
Edge,
NodeProps,
Handle,
Position,
useReactFlow
} 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, ChevronDown, ChevronRight } 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, collapsedNodes, toggleNodeCollapse } = useTopologyStore(useShallow(s => ({
highlightPath: s.highlightPath,
collapsedNodes: s.collapsedNodes,
toggleNodeCollapse: s.toggleNodeCollapse
})));
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string; hasChildren?: boolean };
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;
const isCollapsed = collapsedNodes.includes(id);
return (
<div className="relative group">
<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>
{nodeData.hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
toggleNodeCollapse(id);
}}
className="absolute -right-3 -bottom-3 w-6 h-6 bg-slate-800 border-2 border-slate-600 rounded-full flex items-center justify-center text-slate-400 hover:text-white hover:border-slate-500 hover:bg-slate-700 transition-colors z-10"
title={isCollapsed ? "Expand children" : "Collapse children"}
>
{isCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
)}
</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 };
}
import { useFilteredNodes } from '../../store/topologyStore';
export default function TopologyGraph() {
const {
edges: storeEdges,
selectedNodeId,
setSelectedNode,
orientation,
viewMode,
highlightPath,
collapsedNodes
} = useTopologyStore(useShallow((s) => ({
edges: s.edges,
selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode,
orientation: s.orientation,
viewMode: s.viewMode,
highlightPath: s.highlightPath,
collapsedNodes: s.collapsedNodes
})));
const filteredNodesList = useFilteredNodes();
const { fitView } = useReactFlow();
// Watch for collapse/expand events to recenter the graph nicely
const collapsedNodesHash = collapsedNodes.join(',');
useEffect(() => {
// Wait for dagre to compute the new layout locations before centering
const timeoutId = setTimeout(() => {
// Find the first node available if none is selected
const currentNodes = useTopologyStore.getState().nodes;
const targetNodeId = selectedNodeId || currentNodes[0]?.id;
if (targetNodeId) {
fitView({ nodes: [{ id: targetNodeId }], duration: 800, maxZoom: 1 });
}
}, 100);
return () => clearTimeout(timeoutId);
}, [collapsedNodesHash, fitView, selectedNodeId]);
// Memoize the layout computation instead of useState + useEffect
const { nodes, edges } = useMemo(() => {
const filteredNodes = filteredNodesList;
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,
hasChildren: collapsedNodes.includes(node.id) || storeEdges.some(e => e.source === node.id && nodeIds.has(e.target))
},
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);
}, [filteredNodesList, 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.4}
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>
);
}

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

@@ -0,0 +1,257 @@
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,
toggleSettings
} = 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,
toggleSettings: s.toggleSettings,
})));
const loading = externalLoading ?? storeLoading;
const handleRefresh = useCallback(async () => {
if (onRefresh) {
await onRefresh();
}
}, [onRefresh]);
return (
<header className="h-16 glass border-b border-white/10 px-2 sm:px-4 flex items-center justify-between shrink-0 z-30 relative shadow-lg gap-2">
<div className="flex-1 flex items-center gap-4 overflow-x-auto hide-scrollbar pr-2 sm:pr-4 mask-fade-right">
<div className="flex items-center gap-3 shrink-0 mr-2">
<div className="w-9 h-9 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-[0_0_15px_rgba(99,102,241,0.3)]">
<Network className="w-5 h-5 text-white" />
</div>
<h1 className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-slate-100 to-slate-400 tracking-tight hidden sm:block">Homelab Topology</h1>
</div>
<div className="h-6 w-px bg-white/10 shrink-0" />
<div className="flex items-center gap-1 shrink-0 bg-slate-900/40 p-1 rounded-lg border border-white/5" 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-all duration-200 ${viewMode === mode
? 'bg-indigo-500/20 text-indigo-300 shadow-[0_0_10px_rgba(99,102,241,0.2)]'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
}`}
>
{icon}
<span className="hidden md:inline">{label}</span>
</button>
))}
</div>
<div className="h-6 w-px bg-white/10 shrink-0" />
<div className="flex items-center gap-2 shrink-0">
<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-900/50 border border-white/10 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 cursor-pointer transition-colors hover:bg-slate-800/80"
>
{orientations.map(({ value, label }) => (
<option key={value} value={value} className="bg-slate-800">
{label}
</option>
))}
</select>
</div>
<div className="h-6 w-px bg-white/10 shrink-0" />
<div className="flex items-center gap-1 shrink-0 bg-slate-900/40 p-1 rounded-lg border border-white/5" 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-1.5 md:p-2 rounded-md transition-all duration-200 transform hover:scale-105 ${isActive
? 'border border-transparent'
: 'text-slate-500 hover:text-slate-300 hover:bg-white/5'
}`}
style={isActive ? {
backgroundColor: `${color}20`,
borderColor: `${color}40`,
color: color,
boxShadow: `0 0 10px ${color}20`
} : undefined}
>
{icon}
</button>
);
})}
</div>
<div className="h-6 w-px bg-white/10 shrink-0 hidden md:block" />
<div className="flex items-center gap-2 shrink-0 hidden lg:flex">
<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-900/50 border border-white/10 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500/50 cursor-pointer transition-colors hover:bg-slate-800/80"
>
<option value="all" className="bg-slate-800">All Status</option>
<option value="running" className="bg-slate-800">Running</option>
<option value="stopped" className="bg-slate-800">Stopped</option>
</select>
</div>
<div className="h-6 w-px bg-white/10 shrink-0 hidden lg:block" />
<div className="flex items-center gap-2 shrink-0 hidden md:flex">
<Settings className="w-4 h-4 text-slate-500" 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-900/50 border border-white/10 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500/50 cursor-pointer transition-colors hover:bg-slate-800/80"
>
<option value={10000} className="bg-slate-800">10 seconds</option>
<option value={30000} className="bg-slate-800">30 seconds</option>
<option value={60000} className="bg-slate-800">1 minute</option>
<option value={300000} className="bg-slate-800">5 minutes</option>
</select>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-3 shrink-0 ml-auto pl-2 sm:pl-4 border-l border-white/10">
<div className="relative shrink-0 hidden md:block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" 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-48 lg:w-64 h-9 pl-9 pr-4 bg-slate-900/50 border border-white/10 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
/>
</div>
<button
onClick={handleRefresh}
disabled={loading}
aria-label={loading ? 'Loading data' : 'Refresh data'}
className="h-9 px-2 sm:px-3 flex items-center gap-2 bg-slate-800/80 hover:bg-slate-700/80 border border-white/10 rounded-lg text-sm text-slate-300 transition-all shadow-sm hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed transform hover:-translate-y-0.5"
>
<Loader2 className={`w-4 h-4 ${loading ? 'animate-spin text-indigo-400' : ''}`} aria-hidden="true" />
<span className="hidden lg:inline">{loading ? 'Loading...' : 'Refresh'}</span>
</button>
<div className="h-6 w-px bg-white/10 shrink-0 hidden sm:block" />
<button
onClick={toggleLeftPanel}
aria-label={leftPanelOpen ? 'Hide child nodes panel' : 'Show child nodes panel'}
aria-pressed={leftPanelOpen}
className={`p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-105 ${leftPanelOpen ? 'bg-indigo-500/20 text-indigo-400 shadow-[0_0_10px_rgba(99,102,241,0.2)]' : 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Box className="w-5 h-5 sm:w-5 sm:h-5 w-4 h-4" aria-hidden="true" />
</button>
<button
onClick={toggleRightPanel}
aria-label={rightPanelOpen ? 'Hide details panel' : 'Show details panel'}
aria-pressed={rightPanelOpen}
className={`p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-105 ${rightPanelOpen ? 'bg-indigo-500/20 text-indigo-400 shadow-[0_0_10px_rgba(99,102,241,0.2)]' : 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Database className="w-5 h-5 sm:w-5 sm:h-5 w-4 h-4" aria-hidden="true" />
</button>
<div className="h-6 w-px bg-white/10 shrink-0" />
<button
onClick={toggleSettings}
aria-label="Configuration Settings"
className="p-1.5 sm:p-2 rounded-lg transition-all duration-200 text-slate-400 hover:text-white hover:bg-white/5 hover:scale-105 hover:rotate-90 focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
>
<Settings className="w-5 h-5 sm:w-5 sm:h-5 w-4 h-4" aria-hidden="true" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,139 @@
import { useMemo } from 'react';
import { ChevronRight, Server, Network, Wifi, Box, Database, Folder, X } 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, toggleLeftPanel } = useTopologyStore(
useShallow((s) => ({
nodes: s.nodes,
selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode,
toggleLeftPanel: s.toggleLeftPanel,
}))
);
const selectedNode = nodes.find(n => n.id === selectedNodeId);
const { childNodes, groupedChildren } = useMemo(() => {
const children = nodes.filter(n => n.data.parentId === selectedNodeId);
const grouped = children.reduce((acc, node) => {
if (!acc[node.type]) acc[node.type] = [];
acc[node.type].push(node);
return acc;
}, {} as Record<NodeType, TopologyNode[]>);
return { childNodes: children, groupedChildren: grouped };
}, [nodes, selectedNodeId]);
return (
<aside className="absolute md:relative z-20 h-full w-72 md:w-80 glass-panel border-r border-white/10 flex flex-col shadow-[4px_0_24px_rgba(0,0,0,0.5)] md:shadow-none animate-in slide-in-from-left-4 duration-300" aria-label="Child nodes panel">
<div className="h-14 px-4 flex items-center justify-between border-b border-white/5 bg-slate-900/40 backdrop-blur-md">
<div className="flex items-center">
<h2 className="text-sm font-semibold text-white tracking-wide">
{selectedNode ? 'Child Nodes' : 'Select a Node'}
</h2>
{childNodes.length > 0 && (
<span className="ml-2 px-2 py-0.5 bg-indigo-500/20 text-indigo-300 text-xs rounded-full border border-indigo-500/30">
{childNodes.length}
</span>
)}
</div>
<button
onClick={toggleLeftPanel}
className="p-1 hover:bg-slate-700/50 rounded-lg transition-colors md:hidden"
aria-label="Close panel"
>
<X className="w-5 h-5 text-slate-400" aria-hidden="true" />
</button>
</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-all duration-200 text-left ${selectedNodeId === node.id
? 'bg-indigo-500/20 text-indigo-300 shadow-[0_0_10px_rgba(99,102,241,0.15)]'
: 'text-slate-300 hover:bg-white/5 hover:translate-x-1'
}`}
>
<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-white/5 bg-slate-900/20">
<HostChart />
</div>
</aside>
);
}

148
src/components/Login.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { useTopologyStore } from '../store/topologyStore';
import { Lock, User, Key, Server } from 'lucide-react';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSetupMode, setIsSetupMode] = useState(false);
const { setToken } = useTopologyStore();
useEffect(() => {
// Check if setup is required
fetch(`${API_BASE_URL}/api/auth/status`)
.then(res => res.json())
.then(data => {
if (data.setupRequired) {
setIsSetupMode(true);
}
})
.catch(err => console.error('Failed to fetch auth status', err));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok && data.token) {
setToken(data.token);
} else {
setError(data.message || 'Authentication failed');
}
} catch (err) {
setError('Network error. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4 relative overflow-hidden">
{/* Decorative Background Orbs */}
<div className="absolute top-[10%] left-[20%] w-[500px] h-[500px] bg-indigo-600/20 rounded-full mix-blend-screen filter blur-[100px] opacity-50 animate-pulse-slow"></div>
<div className="absolute bottom-[10%] right-[20%] w-[600px] h-[600px] bg-purple-600/20 rounded-full mix-blend-screen filter blur-[100px] opacity-40 animate-pulse-slow" style={{ animationDelay: '1.5s' }}></div>
<div className="max-w-md w-full glass rounded-2xl shadow-2xl overflow-hidden border border-slate-700/50 z-10 relative">
<div className="p-8 text-center bg-slate-900/40 border-b border-slate-700/50 backdrop-blur-sm">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500/20 to-purple-500/20 text-indigo-400 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.15)] group transition-transform duration-300 hover:scale-105">
<Server size={32} className="group-hover:text-indigo-300 transition-colors" />
</div>
<h2 className="text-2xl font-bold text-slate-100 tracking-tight">Homelab Topology</h2>
<p className="text-slate-400 mt-2 text-sm">
{isSetupMode ? 'Create your admin account' : 'Sign in to access your dashboard'}
</p>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6 bg-slate-900/20">
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 p-3 rounded-lg text-sm flex items-start transform transition-all duration-300 shadow-[0_0_10px_rgba(239,68,68,0.1)]">
{error}
</div>
)}
<div className="space-y-5">
<div className="group">
<label className="block text-sm font-medium text-slate-300 mb-1.5 transition-colors group-hover:text-indigo-300" htmlFor="username">
Username
</label>
<div className="relative overflow-hidden rounded-lg">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500 transition-colors group-focus-within:text-indigo-400">
<User size={18} />
</div>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full pl-10 bg-slate-900/50 border border-slate-700/50 text-slate-100 placeholder-slate-500 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 focus:bg-slate-800/80 sm:text-sm py-2.5 outline-none transition-all duration-300"
placeholder="admin"
required
/>
</div>
</div>
<div className="group">
<label className="block text-sm font-medium text-slate-300 mb-1.5 transition-colors group-hover:text-indigo-300" htmlFor="password">
Password
</label>
<div className="relative overflow-hidden rounded-lg">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500 transition-colors group-focus-within:text-indigo-400">
<Key size={18} />
</div>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 bg-slate-900/50 border border-slate-700/50 text-slate-100 placeholder-slate-500 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 focus:bg-slate-800/80 sm:text-sm py-2.5 outline-none transition-all duration-300"
placeholder="••••••••"
required
pattern={isSetupMode ? ".{8,}" : ".*"}
title={isSetupMode ? "Password must be at least 8 characters long" : ""}
/>
</div>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-[0_0_15px_rgba(99,102,241,0.15)] text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:ring-offset-slate-900 transition-all duration-300 transform hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
>
{isLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
) : (
<span className="flex items-center">
<Lock size={16} className="mr-2" />
{isSetupMode ? 'Create Account & Continue' : 'Sign In'}
</span>
)}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,358 @@
import { useState, useCallback, useEffect } from 'react';
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder, Focus } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useReactFlow } from '@xyflow/react';
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, getChildNodes } = useTopologyStore(
useShallow((s) => ({
nodes: s.nodes,
selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode,
openTerminal: s.openTerminal,
getChildNodes: s.getChildNodes
}))
);
const { fitView } = useReactFlow();
const [activeTab, setActiveTab] = useState<TabId>('details');
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const selectedNode = nodes.find(n => n.id === selectedNodeId);
// Fallback to details tab if usage is active but node isn't a service
useEffect(() => {
if (activeTab === 'usage' && selectedNode && selectedNode.type !== 'service') {
setActiveTab('details');
}
}, [selectedNode, activeTab]);
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();
}, []);
const handleFocus = useCallback(() => {
if (!selectedNodeId) return;
const children = getChildNodes();
const nodesToFocus = [{ id: selectedNodeId }, ...children.map(c => ({ id: c.id }))];
fitView({ nodes: nodesToFocus, duration: 800, padding: 0.2, maxZoom: 1.2 });
}, [selectedNodeId, getChildNodes, fitView]);
if (!selectedNode) {
return (
<aside className="absolute right-0 md:relative z-20 h-full w-80 lg:w-96 glass-panel border-l border-white/10 flex flex-col items-center justify-center p-4 shadow-[-4px_0_24px_rgba(0,0,0,0.5)] md:shadow-none animate-in slide-in-from-right-4 duration-300" aria-label="Node details panel">
<div className="text-slate-400 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="absolute right-0 md:relative z-20 h-full w-80 lg:w-96 glass-panel border-l border-white/10 flex flex-col shadow-[-4px_0_24px_rgba(0,0,0,0.5)] md:shadow-none animate-in slide-in-from-right-4 duration-300" aria-label="Node details panel">
<div className="h-14 px-4 flex items-center justify-between border-b border-white/5 bg-slate-900/40 backdrop-blur-md">
<h2 className="text-sm font-semibold text-white tracking-wide truncate pr-2">{selectedNode.name}</h2>
<div className="flex items-center gap-1">
{isHost && selectedNodeId && (
<>
<button
onClick={handleFocus}
className="p-1 hover:bg-slate-700 rounded transition-colors"
aria-label="Focus node and children"
title="Focus node and children"
>
<Focus className="w-4 h-4 text-slate-400" aria-hidden="true" />
</button>
<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-white/5 bg-slate-900/20" 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-all duration-200 ${activeTab === tab.id
? 'text-indigo-300 bg-indigo-500/20 shadow-[inset_0_-2px_0_rgba(99,102,241,1)]'
: 'text-slate-400 hover:text-white hover:bg-white/5 hover:-translate-y-0.5'
}`}
>
<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/50 border border-white/5 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto shadow-inner">
{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/50 border border-white/5 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto shadow-inner">
{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.5 flex items-center gap-2 bg-slate-900/40 border border-white/5 hover:bg-white/5 hover:border-white/10 rounded-lg text-left transition-all duration-200 transform hover:-translate-y-0.5"
>
<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,236 @@
import { useState, useEffect } from 'react';
import { useTopologyStore } from '../../store/topologyStore';
import { Server, Plus, Trash2, Edit2, CheckCircle2, X } from 'lucide-react';
import { HostConfig } from '../../types';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function HostConfigTab() {
const { token } = useTopologyStore();
const [hosts, setHosts] = useState<HostConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Form state
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<Partial<HostConfig>>({});
useEffect(() => {
fetchHosts();
}, []);
const fetchHosts = async () => {
try {
setIsLoading(true);
const res = await fetch(`${API_BASE_URL}/api/hosts`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch hosts');
const data = await res.json();
setHosts(data.hosts);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
const isUpdate = !!formData.id;
const url = isUpdate ? `${API_BASE_URL}/api/hosts/${formData.id}` : `${API_BASE_URL}/api/hosts`;
const method = isUpdate ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(formData)
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || 'Failed to save host');
}
await fetchHosts();
setIsEditing(false);
setFormData({});
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this host?')) return;
try {
const res = await fetch(`${API_BASE_URL}/api/hosts/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to delete host');
await fetchHosts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
if (isLoading) return <div className="text-slate-400">Loading hosts...</div>;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-white tracking-tight">SSH Targets</h3>
<p className="text-sm text-slate-400">Configure linux hosts for auto-discovery</p>
</div>
{!isEditing && (
<button
onClick={() => { setFormData({ sshPort: 22, sshUser: 'root', hostType: 'docker-host' }); setIsEditing(true); }}
className="flex items-center justify-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-all shadow-[0_0_15px_rgba(99,102,241,0.3)] transform hover:-translate-y-0.5"
>
<Plus className="w-4 h-4" /> Add Host
</button>
)}
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg text-sm shadow-[0_0_10px_rgba(239,68,68,0.1)]">
{error}
</div>
)}
{isEditing ? (
<form onSubmit={handleSave} className="bg-slate-900/40 p-4 md:p-6 rounded-xl border border-white/5 space-y-4 shadow-inner backdrop-blur-sm">
<div className="flex justify-between items-center mb-4">
<h4 className="text-md font-bold text-white">
{formData.id ? 'Edit Host' : 'New Host'}
</h4>
<button type="button" onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Host Name</label>
<input
required
type="text"
value={formData.name || ''}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
placeholder="e.g. primary-server"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">IP Address</label>
<input
required
type="text"
value={formData.ip || ''}
onChange={e => setFormData({ ...formData, ip: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
placeholder="192.168.1.10"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">SSH User</label>
<input
required
type="text"
value={formData.sshUser || ''}
onChange={e => setFormData({ ...formData, sshUser: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">SSH Port</label>
<input
required
type="number"
value={formData.sshPort || 22}
onChange={e => setFormData({ ...formData, sshPort: parseInt(e.target.value) })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">SSH Key Path (Optional)</label>
<input
type="text"
value={formData.sshKeyPath || ''}
onChange={e => setFormData({ ...formData, sshKeyPath: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
placeholder="~/.ssh/id_ed25519"
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Host Type</label>
<select
value={formData.hostType || 'docker-host'}
onChange={e => setFormData({ ...formData, hostType: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all hover:bg-slate-800/50 cursor-pointer"
>
<option value="docker-host" className="bg-slate-800">Docker Host</option>
<option value="proxmox" className="bg-slate-800">Proxmox VE</option>
<option value="truenas" className="bg-slate-800">TrueNAS</option>
</select>
</div>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-white/5 mt-6">
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">Cancel</button>
<button type="submit" className="flex items-center gap-2 px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-all shadow-md transform hover:-translate-y-0.5">
<CheckCircle2 className="w-4 h-4" /> Save
</button>
</div>
</form>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{hosts.length === 0 ? (
<div className="col-span-full py-16 text-center border border-dashed border-white/10 bg-slate-900/20 rounded-xl">
<Server className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<h4 className="text-white font-bold tracking-tight">No hosts configured</h4>
<p className="text-slate-400 text-sm mt-1">Add a host to enable auto-discovery.</p>
</div>
) : (
hosts.map(host => (
<div key={host.id} className="bg-slate-900/40 p-5 rounded-xl border border-white/5 hover:border-indigo-500/30 transition-all duration-300 group shadow-md hover:shadow-[0_0_15px_rgba(99,102,241,0.1)]">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500/20 to-purple-500/20 text-indigo-400 rounded-lg flex items-center justify-center border border-indigo-500/30 shadow-inner">
<Server className="w-6 h-6" />
</div>
<div>
<h4 className="font-bold text-white tracking-tight">{host.name}</h4>
<p className="text-xs text-slate-400 font-mono mt-0.5">{host.ip}</p>
</div>
</div>
<div className="flex opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
<button onClick={() => { setFormData(host); setIsEditing(true); }} className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(host.id!)} className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg ml-1 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-xs text-slate-300 bg-slate-900/60 p-3.5 rounded-lg border border-white/5 shadow-inner font-medium">
<div><span className="text-slate-500 uppercase tracking-wider text-[10px] block mb-0.5">User</span> {host.sshUser}</div>
<div><span className="text-slate-500 uppercase tracking-wider text-[10px] block mb-0.5">Port</span> {host.sshPort}</div>
<div className="col-span-2 truncate"><span className="text-slate-500 uppercase tracking-wider text-[10px] block mb-0.5">Key</span> <span className="font-mono opacity-80">{host.sshKeyPath || 'Default (~/.ssh/id_ed25519)'}</span></div>
<div className="col-span-2"><span className="text-slate-500 uppercase tracking-wider text-[10px] block mb-0.5">Type</span> <span className="bg-indigo-500/20 text-indigo-300 px-2 py-0.5 rounded text-[10px] uppercase tracking-wider border border-indigo-500/20">{host.hostType}</span></div>
</div>
</div>
))
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useState } from 'react';
import { useTopologyStore } from '../../store/topologyStore';
import { Settings, Server, Network, X } from 'lucide-react';
import HostConfigTab from './HostConfigTab';
import TopologyNodeTab from './TopologyNodeTab';
type Tab = 'hosts' | 'topology';
export default function SettingsOverlay() {
const { settingsOpen, toggleSettings } = useTopologyStore();
const [activeTab, setActiveTab] = useState<Tab>('hosts');
if (!settingsOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md p-2 md:p-4">
<div className="glass rounded-xl shadow-2xl w-full max-w-5xl h-[95vh] md:h-[85vh] flex flex-col overflow-hidden border border-white/10 relative">
{/* Header */}
<div className="h-16 border-b border-white/10 bg-slate-900/40 flex items-center justify-between px-4 md:px-6 shrink-0 relative z-10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-500/20 text-indigo-400 rounded-lg flex items-center justify-center border border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.2)]">
<Settings className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-white tracking-tight">Configuration</h2>
<p className="text-xs text-slate-400">Manage hosts and topology</p>
</div>
</div>
<button
onClick={toggleSettings}
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all duration-200 hover:scale-105 hover:rotate-90"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Layout */}
<div className="flex flex-col md:flex-row flex-1 overflow-hidden relative z-10">
{/* Sidebar Tabs */}
<div className="w-full md:w-64 bg-slate-900/40 border-b md:border-b-0 md:border-r border-white/10 p-4 shrink-0 flex flex-row md:flex-col gap-2 overflow-x-auto hide-scrollbar">
<button
onClick={() => setActiveTab('hosts')}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 shrink-0 ${activeTab === 'hosts'
? 'bg-indigo-600 text-white shadow-[0_0_15px_rgba(99,102,241,0.3)] transform -translate-y-0.5'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Server className="w-4 h-4" />
SSH Hosts
</button>
<button
onClick={() => setActiveTab('topology')}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 shrink-0 ${activeTab === 'topology'
? 'bg-indigo-600 text-white shadow-[0_0_15px_rgba(99,102,241,0.3)] transform -translate-y-0.5'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Network className="w-4 h-4" />
Static Topology
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto bg-slate-900/20 p-4 md:p-6 relative">
{activeTab === 'hosts' && <HostConfigTab />}
{activeTab === 'topology' && <TopologyNodeTab />}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,224 @@
import { useState, useEffect } from 'react';
import { useTopologyStore } from '../../store/topologyStore';
import { Network, Plus, Trash2, Edit2, CheckCircle2, X } from 'lucide-react';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function TopologyNodeTab() {
const { token, nodes: currentLiveNodes } = useTopologyStore();
const [dbNodes, setDbNodes] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<any>({});
useEffect(() => {
fetchNodes();
}, []);
const fetchNodes = async () => {
try {
setIsLoading(true);
const res = await fetch(`${API_BASE_URL}/api/topology`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch topology nodes');
const data = await res.json();
setDbNodes(data.nodes);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
const isUpdate = !!formData.id;
const url = isUpdate ? `${API_BASE_URL}/api/topology/nodes/${formData.id}` : `${API_BASE_URL}/api/topology/nodes`;
const payload = {
name: formData.name,
type: formData.type,
ip: formData.ip || null,
mac: formData.mac || null,
os: formData.os || null,
status: formData.status || 'unknown',
parentId: formData.parentId || null
};
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Failed to save network node');
await fetchNodes();
setIsEditing(false);
setFormData({});
setError(null);
// Trigger a refresh on the main UI
fetch(`${API_BASE_URL}/api/discover`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } });
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this static node?')) return;
try {
const res = await fetch(`${API_BASE_URL}/api/topology/nodes/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to delete network node');
await fetchNodes();
fetch(`${API_BASE_URL}/api/discover`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } });
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
if (isLoading) return <div className="text-slate-400">Loading topology nodes...</div>;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-white tracking-tight">Static Topology</h3>
<p className="text-sm text-slate-400">Manually add network devices (Gateways, Switches, WiFi)</p>
</div>
{!isEditing && (
<button
onClick={() => { setFormData({ type: 'gateway', status: 'running' }); setIsEditing(true); }}
className="flex items-center justify-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-all shadow-[0_0_15px_rgba(99,102,241,0.3)] transform hover:-translate-y-0.5"
>
<Plus className="w-4 h-4" /> Add Node
</button>
)}
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg text-sm shadow-[0_0_10px_rgba(239,68,68,0.1)]">
{error}
</div>
)}
{isEditing ? (
<form onSubmit={handleSave} className="bg-slate-900/40 p-4 md:p-6 rounded-xl border border-white/5 space-y-4 shadow-inner backdrop-blur-sm">
<div className="flex justify-between items-center mb-4">
<h4 className="text-md font-bold text-white">
{formData.id ? 'Edit Static Node' : 'New Static Node'}
</h4>
<button type="button" onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Device Name</label>
<input
required
type="text"
value={formData.name || ''}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
placeholder="e.g. Unifi Dream Machine"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Device Type</label>
<select
value={formData.type || 'gateway'}
onChange={e => setFormData({ ...formData, type: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all hover:bg-slate-800/50 cursor-pointer"
>
<option value="gateway" className="bg-slate-800">Gateway / Router</option>
<option value="vlan" className="bg-slate-800">Switch / VLAN</option>
<option value="wifi" className="bg-slate-800">Access Point</option>
<option value="host_physical" className="bg-slate-800">Physical Device</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">IP Address (Optional)</label>
<input
type="text"
value={formData.ip || ''}
onChange={e => setFormData({ ...formData, ip: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Parent Node (Optional)</label>
<select
value={formData.parentId || ''}
onChange={e => setFormData({ ...formData, parentId: e.target.value || null })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all hover:bg-slate-800/50 cursor-pointer"
>
<option value="" className="bg-slate-800">None (Root Node)</option>
{currentLiveNodes
.filter(n => n.id !== formData.id)
.map(node => (
<option key={node.id} value={node.id} className="bg-slate-800">{node.name}</option>
))
}
</select>
</div>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-white/5 mt-6">
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">Cancel</button>
<button type="submit" className="flex items-center gap-2 px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-all shadow-md transform hover:-translate-y-0.5">
<CheckCircle2 className="w-4 h-4" /> Save
</button>
</div>
</form>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{dbNodes.length === 0 ? (
<div className="col-span-full py-16 text-center border border-dashed border-white/10 bg-slate-900/20 rounded-xl">
<Network className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<h4 className="text-white font-bold tracking-tight">No static nodes</h4>
<p className="text-slate-400 text-sm mt-1">Add gateways or switches to map your core network.</p>
</div>
) : (
dbNodes.map(node => (
<div key={node.id} className="bg-slate-900/40 p-5 rounded-xl border border-white/5 hover:border-indigo-500/30 transition-all duration-300 group shadow-md hover:shadow-[0_0_15px_rgba(99,102,241,0.1)] flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-emerald-500/20 to-teal-500/20 text-emerald-400 rounded-lg flex items-center justify-center border border-emerald-500/30 shadow-inner">
<Network className="w-6 h-6" />
</div>
<div>
<h4 className="font-bold text-white tracking-tight">{node.name}</h4>
<div className="flex items-center gap-2 mt-0.5">
<span className="bg-emerald-500/20 text-emerald-300 px-2 py-0.5 rounded text-[10px] uppercase tracking-wider border border-emerald-500/20">
{node.type.replace('_', ' ')}
</span>
{node.ip && <span className="text-xs text-slate-400 font-mono">{node.ip}</span>}
</div>
</div>
</div>
<div className="flex opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
<button onClick={() => { setFormData(node); setIsEditing(true); }} className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(node.id)} className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg ml-1 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</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,138 @@
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';
import { useTopologyStore } from '../store/topologyStore';
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',
'Authorization': `Bearer ${useTopologyStore.getState().token}`
},
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-black/80 backdrop-blur-md flex items-center justify-center p-2 sm:p-4">
<div className="w-full max-w-6xl h-[95vh] sm:h-[90vh] glass rounded-xl shadow-[0_0_40px_rgba(0,0,0,0.8)] border border-white/10 flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between px-4 py-3 bg-slate-900/80 border-b border-white/10 backdrop-blur-md shrink-0">
<div className="flex items-center gap-2 overflow-x-auto hide-scrollbar">
<span className="text-cyan-400 font-mono font-bold">$</span>
<span className="text-slate-200 font-bold tracking-tight whitespace-nowrap">{host}</span>
<span className="text-slate-500 font-bold">:</span>
<span className="text-slate-400 font-mono text-sm whitespace-nowrap">{currentPath}</span>
</div>
<button
onClick={onClose}
className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all duration-200 hover:scale-105 hover:rotate-90 shrink-0 ml-4"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 bg-[#0f172a] p-2 relative">
<div ref={terminalRef} className="absolute inset-2" />
</div>
</div>
</div>
);
}

164
src/index.css Normal file
View File

@@ -0,0 +1,164 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@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;
}
/* ── Custom Scrollbar ────────────────────────────────────── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* ── Glassmorphism Utility ───────────────────────────────── */
.glass {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-panel {
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-right: 1px solid rgba(255, 255, 255, 0.05);
border-left: 1px solid rgba(255, 255, 255, 0.05);
}
/* ── 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,113 @@
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();
});
});
/* ----------------------------------------------------------------
* 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');
});
});
});

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

@@ -0,0 +1,370 @@
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;
collapsedNodes: string[];
token: string | null;
setToken: (token: string | null) => void;
logout: () => void;
settingsOpen: boolean;
toggleSettings: () => void;
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;
toggleNodeCollapse: (nodeId: string) => void;
getSelectedNode: () => TopologyNode | null;
getChildNodes: () => 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: typeof window !== 'undefined' ? window.innerWidth >= 768 : true,
rightPanelOpen: false,
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,
collapsedNodes: [],
token: null,
settingsOpen: false,
setToken: (token) => set({ token }),
logout: () => set({ token: null }),
toggleSettings: () => set((state) => ({ settingsOpen: !state.settingsOpen })),
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);
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
set({
selectedNodeId: nodeId,
highlightPath: path,
rightPanelOpen: true,
...(isMobile ? { leftPanelOpen: false } : {})
});
},
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) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return {
leftPanelOpen: !state.leftPanelOpen,
...(isMobile && !state.leftPanelOpen ? { rightPanelOpen: false } : {})
};
}),
toggleRightPanel: () => set((state) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return {
rightPanelOpen: !state.rightPanelOpen,
...(isMobile && !state.rightPanelOpen ? { leftPanelOpen: false } : {})
};
}),
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 }),
toggleNodeCollapse: (nodeId) => set((state) => {
const isCollapsing = !state.collapsedNodes.includes(nodeId);
if (isCollapsing) {
// Standard collapse behavior: add the node to the list.
return {
collapsedNodes: [...state.collapsedNodes, nodeId]
};
} else {
// Expansion behavior (Progressive Disclosure):
// Remove the parent node from the collapsed list so its children are visible.
// But immediately collapse those newly revealed children so they don't burst open their own entire subtrees.
const immediateChildrenIds = state.nodes
.filter(n => n.data.parentId === nodeId)
.map(n => n.id);
const newCollapsedNodes = state.collapsedNodes.filter(id => id !== nodeId);
// Add the children to the collapsed list if they aren't already there
immediateChildrenIds.forEach(childId => {
if (!newCollapsedNodes.includes(childId)) {
newCollapsedNodes.push(childId);
}
});
return {
collapsedNodes: newCollapsedNodes
};
}
}),
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);
}
}),
{
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,
pollInterval: state.pollInterval,
collapsedNodes: state.collapsedNodes,
nodes: state.nodes,
edges: state.edges,
hosts: state.hosts,
networkInfo: state.networkInfo,
lastUpdated: state.lastUpdated,
token: state.token
})
}), { 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);
});
import { useMemo } from 'react';
export const useFilteredNodes = () => {
const nodes = useTopologyStore((s) => s.nodes);
const viewMode = useTopologyStore((s) => s.viewMode);
const searchQuery = useTopologyStore((s) => s.searchQuery);
const typeFilters = useTopologyStore((s) => s.typeFilters);
const statusFilter = useTopologyStore((s) => s.statusFilter);
const collapsedNodes = useTopologyStore((s) => s.collapsedNodes);
return useMemo(() => {
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));
}
// Filter out children of collapsed nodes
if (collapsedNodes.length > 0) {
const nodeMap = new Map(nodes.map(n => [n.id, n]));
const collapsedSet = new Set(collapsedNodes);
const visibilityCache = new Map<string, boolean>();
filtered = filtered.filter(node => {
// Start from the current node's parent
let currentId: string | undefined = node.data.parentId;
// Check if we already know the answer
if (currentId && visibilityCache.has(currentId)) {
return visibilityCache.get(currentId) !== false;
}
// Traverse up the tree to see if any ancestor is collapsed
let isHidden = false;
const traversalPath: string[] = [];
while (currentId) {
traversalPath.push(currentId);
// If this ancestor is collapsed, the child is hidden
if (collapsedSet.has(currentId)) {
isHidden = true;
break;
}
// If we hit an ancestor in the cache, use its result
if (visibilityCache.has(currentId)) {
isHidden = visibilityCache.get(currentId) === false;
break;
}
// Move up to the next parent
currentId = nodeMap.get(currentId)?.data?.parentId;
}
// Cache the result for this specific parent (and all parents in its path)
let parentToCache = node.data.parentId;
if (parentToCache) {
// If it's hidden, the immediate parent must be marked hidden so sibling nodes immediately abort
visibilityCache.set(parentToCache, !isHidden);
}
return !isHidden; // Show node if not hidden
});
}
return filtered;
}, [nodes, viewMode, searchQuery, typeFilters, statusFilter, collapsedNodes]);
};

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

@@ -0,0 +1,150 @@
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 HostConfig {
id?: string;
name: string;
ip: string;
sshUser: string;
sshKeyPath?: string | null;
sshPort: number;
hostType: string;
}
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/Login.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/components/Settings/HostConfigTab.tsx","./src/components/Settings/SettingsOverlay.tsx","./src/components/Settings/TopologyNodeTab.tsx","./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/**'],
},
},
});