Merge master into main
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.vscode/
|
||||||
|
coverage/
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
6
.sisyphus/boulder.json
Normal file
6
.sisyphus/boulder.json
Normal 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"]
|
||||||
|
}
|
||||||
27
.sisyphus/notepads/remaining-features/learnings.md
Normal file
27
.sisyphus/notepads/remaining-features/learnings.md
Normal 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`
|
||||||
1193
.sisyphus/plans/remaining-features.md
Normal file
1193
.sisyphus/plans/remaining-features.md
Normal file
File diff suppressed because it is too large
Load Diff
86
AGENTS.md
Normal file
86
AGENTS.md
Normal 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
37
Dockerfile
Normal 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"]
|
||||||
76
README.md
76
README.md
@@ -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
487
SPEC.md
Normal 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
941
bun.lock
Normal 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
20
dist/index.html
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Homelab Topology Visualizer</title>
|
||||||
|
<meta name="description" content="Interactive graph-based visualizer for homelab network topology — hosts, containers, services, and storage." />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<script type="module" crossorigin src="/assets/index-BsyZf1s0.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/graph-vendor-C44rQwKI.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DpLh-vKM.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-soHg8pn4.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
docker-compose.yml
Normal file
48
docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Frontend dev server (Vite HMR)
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./index.html:/app/index.html
|
||||||
|
- /app/node_modules
|
||||||
|
command: npm run dev -- --host 0.0.0.0
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://backend:3001
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# Backend API + WebSocket server
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ./server:/app/server
|
||||||
|
- /app/node_modules
|
||||||
|
command: npx tsx --watch server/index.ts
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- CORS_ORIGIN=http://localhost:3000
|
||||||
|
- LOG_LEVEL=debug
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: homelab-topology
|
||||||
17
docker/Dockerfile
Normal file
17
docker/Dockerfile
Normal 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
24
docker/nginx.conf
Normal 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
17
index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Homelab Topology Visualizer</title>
|
||||||
|
<meta name="description" content="Interactive graph-based visualizer for homelab network topology — hosts, containers, services, and storage." />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8013
package-lock.json
generated
Normal file
8013
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
package.json
Normal file
66
package.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "homelab-topology",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"discover": "npx tsx src/services/sshDiscovery.ts",
|
||||||
|
"server": "tsx server/index.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"@xyflow/react": "^12.4.4",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"pino": "^10.3.1",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^2.15.0",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
|
"ssh2": "^1.17.0",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/express-rate-limit": "^5.1.3",
|
||||||
|
"@types/jsdom": "^27.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/pino": "^7.0.4",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@types/ssh2": "^1.15.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"typescript-eslint": "^8.18.2",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
60
server/AGENTS.md
Normal file
60
server/AGENTS.md
Normal 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
|
||||||
23
server/config.example.json
Normal file
23
server/config.example.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"hosts": [
|
||||||
|
{
|
||||||
|
"name": "ubuntu",
|
||||||
|
"ip": "192.168.50.61",
|
||||||
|
"sshUser": "bear",
|
||||||
|
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||||
|
"sshPort": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "grizzley",
|
||||||
|
"ip": "192.168.50.84",
|
||||||
|
"sshUser": "bear",
|
||||||
|
"sshKeyPath": "~/.ssh/id_ed25519"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "truenas",
|
||||||
|
"ip": "192.168.50.12",
|
||||||
|
"sshUser": "root",
|
||||||
|
"sshKeyPath": "~/.ssh/id_ed25519"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
76
server/config.ts
Normal file
76
server/config.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { HostConfig } from './types';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const CONFIG_FILE = path.join(__dirname, 'hosts.json');
|
||||||
|
|
||||||
|
function parseEnvHosts(): HostConfig[] {
|
||||||
|
const hostsEnv = process.env.SSH_HOSTS;
|
||||||
|
if (!hostsEnv) return [];
|
||||||
|
|
||||||
|
const hosts: HostConfig[] = [];
|
||||||
|
const entries = hostsEnv.split(',').map(h => h.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const [name, ip] = entry.split(':');
|
||||||
|
if (name && ip) {
|
||||||
|
hosts.push({
|
||||||
|
name: name.trim(),
|
||||||
|
ip: ip.trim(),
|
||||||
|
sshUser: process.env.SSH_USER || 'bear',
|
||||||
|
sshKeyPath: process.env.SSH_KEY,
|
||||||
|
sshPort: process.env.SSH_PORT ? parseInt(process.env.SSH_PORT, 10) : 22,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonConfig(): HostConfig[] {
|
||||||
|
if (!fs.existsSync(CONFIG_FILE)) {
|
||||||
|
console.error('Config file not found:', CONFIG_FILE);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
|
||||||
|
if (!data.hosts || !Array.isArray(data.hosts)) {
|
||||||
|
console.error('No hosts array in config');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hosts = data.hosts.map((h: Partial<HostConfig>) => ({
|
||||||
|
name: h.name || '',
|
||||||
|
ip: h.ip || '',
|
||||||
|
sshUser: h.sshUser || 'bear',
|
||||||
|
sshKeyPath: h.sshKeyPath?.replace(/^~/, homedir()),
|
||||||
|
sshPort: h.sshPort || 22,
|
||||||
|
})).filter((h: HostConfig) => h.name && h.ip);
|
||||||
|
|
||||||
|
console.error('Loaded hosts:', JSON.stringify(hosts));
|
||||||
|
return hosts;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Config parse error:', e.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHostConfigs(): HostConfig[] {
|
||||||
|
const envHosts = parseEnvHosts();
|
||||||
|
if (envHosts.length > 0) {
|
||||||
|
return envHosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseJsonConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasConfig(): boolean {
|
||||||
|
return getHostConfigs().length > 0;
|
||||||
|
}
|
||||||
36
server/errors.ts
Normal file
36
server/errors.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Custom Error Classes for Homelab Topology API
|
||||||
|
*
|
||||||
|
* Structured error hierarchy following nodejs-backend-patterns skill.
|
||||||
|
* All operational errors extend AppError with appropriate HTTP status codes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
public message: string,
|
||||||
|
public statusCode: number = 500,
|
||||||
|
public isOperational: boolean = true
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
Object.setPrototypeOf(this, AppError.prototype);
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(message: string = 'Resource not found') {
|
||||||
|
super(message, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message: string, public errors?: Array<{ field: string; message: string }>) {
|
||||||
|
super(message, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServiceUnavailableError extends AppError {
|
||||||
|
constructor(message: string = 'Service temporarily unavailable') {
|
||||||
|
super(message, 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
server/hosts.json
Normal file
52
server/hosts.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"hosts": [
|
||||||
|
{
|
||||||
|
"name": "ubuntu",
|
||||||
|
"ip": "192.168.50.61",
|
||||||
|
"sshUser": "bear",
|
||||||
|
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||||
|
"sshPort": 22,
|
||||||
|
"hostType": "docker-host"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "grizzley",
|
||||||
|
"ip": "192.168.50.84",
|
||||||
|
"sshUser": "bear",
|
||||||
|
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||||
|
"sshPort": 22,
|
||||||
|
"hostType": "docker-host"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "truenas",
|
||||||
|
"ip": "192.168.50.12",
|
||||||
|
"sshUser": "root",
|
||||||
|
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||||
|
"sshPort": 22,
|
||||||
|
"hostType": "truenas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proxmox",
|
||||||
|
"ip": "192.168.50.11",
|
||||||
|
"sshUser": "root",
|
||||||
|
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||||
|
"sshPort": 22,
|
||||||
|
"hostType": "proxmox"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ice",
|
||||||
|
"ip": "192.168.50.197",
|
||||||
|
"sshUser": "bear",
|
||||||
|
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||||
|
"sshPort": 22,
|
||||||
|
"hostType": "docker-host"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "panda",
|
||||||
|
"ip": "192.168.50.196",
|
||||||
|
"sshUser": "bear",
|
||||||
|
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||||
|
"sshPort": 22,
|
||||||
|
"hostType": "docker-host"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
107
server/index.ts
Normal file
107
server/index.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import discoverRouter from './routes/discover';
|
||||||
|
import configRouter from './routes/config';
|
||||||
|
import statsRouter from './routes/stats';
|
||||||
|
import filesRouter from './routes/files';
|
||||||
|
import terminalRouter from './routes/terminal';
|
||||||
|
import { getHostConfigs } from './config';
|
||||||
|
import { requestLogger, logger } from './middleware/requestLogger';
|
||||||
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const PORT = 3001;
|
||||||
|
|
||||||
|
// --- Socket.IO setup (websocket-engineer skill) ---
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
pingInterval: 25000,
|
||||||
|
pingTimeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket: Socket) => {
|
||||||
|
logger.info({ socketId: socket.id }, 'Client connected via WebSocket');
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason: string) => {
|
||||||
|
logger.info({ socketId: socket.id, reason }, 'Client disconnected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export io so routes can emit events
|
||||||
|
export { io };
|
||||||
|
|
||||||
|
// --- Security middleware (api-security-hardening skill) ---
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false, // Disable CSP for dev — configure per-environment in production
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CORS — restrict to configured origins
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Rate limiting — general API
|
||||||
|
app.use('/api/', rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 200,
|
||||||
|
message: { status: 'error', message: 'Too many requests, please try again later' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stricter rate limiting for discovery (expensive operation)
|
||||||
|
app.use('/api/discover', rateLimit({
|
||||||
|
windowMs: 1 * 60 * 1000, // 1 minute
|
||||||
|
max: 10,
|
||||||
|
message: { status: 'error', message: 'Discovery rate limited — max 10 per minute' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Body parsing ---
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
// --- Observability (observability-monitoring skill) ---
|
||||||
|
app.use(requestLogger);
|
||||||
|
|
||||||
|
// --- Health check ---
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
uptime: process.uptime(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
connections: io.engine.clientsCount,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Debug endpoint (dev only) ---
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
app.get('/api/debug-config', (_req, res) => {
|
||||||
|
const hosts = getHostConfigs();
|
||||||
|
res.json({ hosts });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Routes ---
|
||||||
|
app.use('/api', discoverRouter);
|
||||||
|
app.use('/api', configRouter);
|
||||||
|
app.use('/api', statsRouter);
|
||||||
|
app.use('/api', filesRouter);
|
||||||
|
app.use('/api', terminalRouter);
|
||||||
|
|
||||||
|
// --- Global error handler (must be last) ---
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// --- Start server ---
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
logger.info(`Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
42
server/middleware/errorHandler.ts
Normal file
42
server/middleware/errorHandler.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
42
server/middleware/requestLogger.ts
Normal file
42
server/middleware/requestLogger.ts
Normal 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
49
server/routes/AGENTS.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Server Routes (API Endpoints)
|
||||||
|
|
||||||
|
**Generated:** 2026-02-19
|
||||||
|
**Location:** server/routes/
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
Express router modules exposing REST API endpoints for the homelab topology.
|
||||||
|
|
||||||
|
## ENDPOINTS
|
||||||
|
|
||||||
|
| File | Route | Method | Purpose |
|
||||||
|
|------|-------|--------|---------|
|
||||||
|
| discover.ts | /api/discover | POST | Run SSH discovery on specified hosts |
|
||||||
|
| config.ts | /api/config | GET/PUT | Get or update configuration |
|
||||||
|
| stats.ts | /api/stats | GET | Retrieve statistics |
|
||||||
|
| files.ts | /api/files | GET | Get file topology |
|
||||||
|
|
||||||
|
## ADDING A NEW ENDPOINT
|
||||||
|
|
||||||
|
1. Create `server/routes/{name}.ts`:
|
||||||
|
```typescript
|
||||||
|
import { Router } from 'express';
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/{name}', (req, res) => {
|
||||||
|
// implementation
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Import and mount in `server/index.ts`:
|
||||||
|
```typescript
|
||||||
|
import newRouter from './routes/{name}';
|
||||||
|
app.use('/api', newRouter);
|
||||||
|
```
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
|
||||||
|
- All routes prefixed with `/api` (mounted in index.ts)
|
||||||
|
- Return JSON on success: `{ data: ... }`
|
||||||
|
- On error: `{ error: string }`
|
||||||
|
- Use async/await for async operations
|
||||||
|
|
||||||
|
## NOTES
|
||||||
|
|
||||||
|
- discover.ts: Main endpoint - accepts host list, returns topology data
|
||||||
|
- CORS is configured at server/index.ts level, not per-route
|
||||||
215
server/routes/config.ts
Normal file
215
server/routes/config.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* SSH Config API Endpoint
|
||||||
|
*
|
||||||
|
* GET /api/config/:host/:container - Get docker-compose config for a specific container
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { Client } from 'ssh2';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { getHostConfigs } from '../config';
|
||||||
|
import { ConfigResponse } from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
interface SSHConnectionConfig {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
username: string;
|
||||||
|
privateKey?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSSH(config: SSHConnectionConfig, timeout: number = 30000): Promise<Client> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const conn = new Client();
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
conn.end();
|
||||||
|
reject(new Error('Connection timeout'));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
conn.on('ready', () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(conn);
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('error', (err) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.connect({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port || 22,
|
||||||
|
username: config.username,
|
||||||
|
privateKey: config.privateKey,
|
||||||
|
readyTimeout: timeout,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execSSH(conn: Client, command: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
conn.exec(command, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let output = '';
|
||||||
|
stream.on('close', () => resolve(output));
|
||||||
|
stream.on('data', (data: Buffer) => { output += data.toString(); });
|
||||||
|
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find docker-compose.yml files on the remote host
|
||||||
|
async function findDockerComposeFiles(conn: Client): Promise<string[]> {
|
||||||
|
const command = `find /home -name "docker-compose.yml" 2>/dev/null | head -10`;
|
||||||
|
const output = await execSSH(conn, command);
|
||||||
|
|
||||||
|
const files = output
|
||||||
|
.split('\n')
|
||||||
|
.map(f => f.trim())
|
||||||
|
.filter(f => f.length > 0);
|
||||||
|
|
||||||
|
// Also check common locations
|
||||||
|
const commonPaths = [
|
||||||
|
`${homedir()}/docker-compose.yml`,
|
||||||
|
'/opt/docker-compose.yml',
|
||||||
|
'/root/docker-compose.yml'
|
||||||
|
];
|
||||||
|
|
||||||
|
return [...new Set([...files, ...commonPaths])];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract a specific service from docker-compose.yml
|
||||||
|
function extractServiceFromYaml(fullYaml: string, serviceName: string): string {
|
||||||
|
const lines = fullYaml.split('\n');
|
||||||
|
let inService = false;
|
||||||
|
let serviceLines: string[] = [];
|
||||||
|
let indentLevel = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Check if we're starting the target service
|
||||||
|
if (line.match(new RegExp(`^\\s+${serviceName}:`)) || line.match(new RegExp(`^${serviceName}:`))) {
|
||||||
|
inService = true;
|
||||||
|
indentLevel = line.match(/^(\s*)/)![1].length;
|
||||||
|
serviceLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inService) {
|
||||||
|
const currentIndent = line.match(/^(\s*)/)![1].length;
|
||||||
|
|
||||||
|
// If we've dedented back to or past the service level, we're done
|
||||||
|
if (line.trim() && currentIndent <= indentLevel) {
|
||||||
|
// Check if this is another top-level key (services, volumes, networks)
|
||||||
|
if (line.match(/^(services|volumes|networks):/)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// If this is another service, we're done
|
||||||
|
if (!line.startsWith(' ')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceLines.length === 0) {
|
||||||
|
return `# Service "${serviceName}" not found in docker-compose.yml`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return serviceLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read docker-compose.yml from remote host and extract service
|
||||||
|
async function getContainerConfig(conn: Client, containerName: string): Promise<ConfigResponse> {
|
||||||
|
const files = await findDockerComposeFiles(conn);
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
try {
|
||||||
|
const command = `cat "${filePath}"`;
|
||||||
|
const content = await execSSH(conn, command);
|
||||||
|
|
||||||
|
if (content && content.includes(containerName)) {
|
||||||
|
const serviceConfig = extractServiceFromYaml(content, containerName);
|
||||||
|
return {
|
||||||
|
yaml: serviceConfig,
|
||||||
|
path: filePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error reading ${filePath}:`, err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
yaml: '',
|
||||||
|
path: '',
|
||||||
|
error: `No docker-compose.yml found with service "${containerName}"`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/config/:host/:container
|
||||||
|
router.get('/config/:host/:container', async (req, res) => {
|
||||||
|
const { host, container } = req.params;
|
||||||
|
|
||||||
|
console.log(`Fetching config for ${container} on ${host}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find host config
|
||||||
|
const hostConfigs = getHostConfigs();
|
||||||
|
const hostConfig = hostConfigs.find(h => h.name === host);
|
||||||
|
|
||||||
|
if (!hostConfig) {
|
||||||
|
return res.status(404).json({
|
||||||
|
yaml: '',
|
||||||
|
path: '',
|
||||||
|
error: `Host "${host}" not found in configuration`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read SSH key
|
||||||
|
let privateKey: Buffer | undefined;
|
||||||
|
if (hostConfig.sshKeyPath) {
|
||||||
|
const keyPath = hostConfig.sshKeyPath.replace(/^~/, homedir());
|
||||||
|
try {
|
||||||
|
privateKey = readFileSync(keyPath);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({
|
||||||
|
yaml: '',
|
||||||
|
path: '',
|
||||||
|
error: `Failed to read SSH key: ${err}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to host via SSH
|
||||||
|
const conn = await connectSSH({
|
||||||
|
host: hostConfig.ip,
|
||||||
|
port: hostConfig.sshPort || 22,
|
||||||
|
username: hostConfig.sshUser,
|
||||||
|
privateKey
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Get container config
|
||||||
|
const config = await getContainerConfig(conn, container);
|
||||||
|
conn.end();
|
||||||
|
|
||||||
|
res.json(config);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching config:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
yaml: '',
|
||||||
|
path: '',
|
||||||
|
error: err.message || 'Failed to fetch config'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
169
server/routes/discover.ts
Normal file
169
server/routes/discover.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* SSH Discovery API Endpoint
|
||||||
|
*
|
||||||
|
* POST /api/discover - Discover all hosts via SSH and return their status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { getHostConfigs } from '../config';
|
||||||
|
import { DiscoveryResponse } from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
interface HostDiscoveryResult {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
online: boolean;
|
||||||
|
containers?: string[];
|
||||||
|
services?: string[];
|
||||||
|
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverHost(
|
||||||
|
name: string,
|
||||||
|
ip: string,
|
||||||
|
sshUser: string,
|
||||||
|
sshKeyPath?: string,
|
||||||
|
sshPort?: number,
|
||||||
|
hostType?: string
|
||||||
|
): Promise<HostDiscoveryResult> {
|
||||||
|
try {
|
||||||
|
const keyPath = (sshKeyPath || `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
|
||||||
|
console.error(`DEBUG: ${name} keyPath=${keyPath}, user=${sshUser}`);
|
||||||
|
const keyArg = `-i ${keyPath}`;
|
||||||
|
const portArg = sshPort && sshPort !== 22 ? `-p ${sshPort}` : '';
|
||||||
|
|
||||||
|
const dockerCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "docker ps --format '{{.Names}}'" 2>/dev/null`;
|
||||||
|
const dockerOutput = execSync(dockerCmd, { encoding: 'utf-8', timeout: 15000 });
|
||||||
|
const containers = dockerOutput.trim().split('\n').filter(c => c.trim());
|
||||||
|
|
||||||
|
let services: string[] = [];
|
||||||
|
if (hostType !== 'proxmox') {
|
||||||
|
try {
|
||||||
|
const systemdCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "systemctl list-units --type=service --state=running --no-pager --no-legend --quiet | awk '{print \\$1}'" 2>/dev/null`;
|
||||||
|
const systemdOutput = execSync(systemdCmd, { encoding: 'utf-8', timeout: 10000 });
|
||||||
|
services = systemdOutput.trim().split('\n').filter(s => s.trim());
|
||||||
|
} catch {
|
||||||
|
console.error(`DEBUG: ${name} systemd discovery failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vms: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }> = [];
|
||||||
|
if (hostType === 'proxmox' || name === 'proxmox') {
|
||||||
|
try {
|
||||||
|
const lxcCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "pct list" 2>/dev/null`;
|
||||||
|
const lxcOutput = execSync(lxcCmd, { encoding: 'utf-8', timeout: 10000 });
|
||||||
|
const lxcLines = lxcOutput.trim().split('\n').slice(1);
|
||||||
|
for (const line of lxcLines) {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'lxc' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "qm list" 2>/dev/null`;
|
||||||
|
const vmOutput = execSync(vmCmd, { encoding: 'utf-8', timeout: 10000 });
|
||||||
|
const vmLines = vmOutput.trim().split('\n').slice(1);
|
||||||
|
for (const line of vmLines) {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'qemu' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error(`DEBUG: ${name} Proxmox discovery failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
online: true,
|
||||||
|
containers,
|
||||||
|
services: services.length > 0 ? services : undefined,
|
||||||
|
vms: vms.length > 0 ? vms : undefined,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
online: false,
|
||||||
|
containers: [],
|
||||||
|
error: error.message || 'Discovery failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/discover - Discover all hosts via SSH
|
||||||
|
router.post('/discover', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const hosts = getHostConfigs();
|
||||||
|
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
const response: DiscoveryResponse = {
|
||||||
|
hosts: [],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
errors: ['No hosts configured'],
|
||||||
|
};
|
||||||
|
return res.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: HostDiscoveryResult[] = [];
|
||||||
|
for (const host of hosts) {
|
||||||
|
try {
|
||||||
|
const keyPath = host.sshKeyPath?.replace(/^~/, homedir());
|
||||||
|
const result = await discoverHost(
|
||||||
|
host.name,
|
||||||
|
host.ip,
|
||||||
|
host.sshUser,
|
||||||
|
keyPath,
|
||||||
|
host.sshPort,
|
||||||
|
host.hostType
|
||||||
|
);
|
||||||
|
results.push(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
results.push({
|
||||||
|
name: host.name,
|
||||||
|
ip: host.ip,
|
||||||
|
online: false,
|
||||||
|
containers: [],
|
||||||
|
error: error.message || 'Discovery failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
results.forEach((result: HostDiscoveryResult) => {
|
||||||
|
if (!result.online && result.error) {
|
||||||
|
errors.push(`${result.name}: ${result.error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: DiscoveryResponse = {
|
||||||
|
hosts: results.map(r => ({
|
||||||
|
name: r.name,
|
||||||
|
ip: r.ip,
|
||||||
|
online: r.online,
|
||||||
|
containers: r.containers,
|
||||||
|
services: r.services,
|
||||||
|
vms: r.vms,
|
||||||
|
})),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error: any) {
|
||||||
|
const response: DiscoveryResponse = {
|
||||||
|
hosts: [],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
errors: [error.message || 'Discovery failed'],
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
265
server/routes/files.ts
Normal file
265
server/routes/files.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Files API Endpoint
|
||||||
|
*
|
||||||
|
* GET /api/files/:host/:container - Get volume mounts for a container
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { Client } from 'ssh2';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { getHostConfigs } from '../config';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
interface SSHConnectionConfig {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
username: string;
|
||||||
|
privateKey?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VolumeMount {
|
||||||
|
source: string;
|
||||||
|
destination: string;
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilesResponse {
|
||||||
|
volumes: VolumeMount[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSSH(config: SSHConnectionConfig, timeout: number = 30000): Promise<Client> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const conn = new Client();
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
conn.end();
|
||||||
|
reject(new Error('Connection timeout'));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
conn.on('ready', () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(conn);
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('error', (err) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.connect({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port || 22,
|
||||||
|
username: config.username,
|
||||||
|
privateKey: config.privateKey,
|
||||||
|
readyTimeout: timeout,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execSSH(conn: Client, command: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
conn.exec(command, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let output = '';
|
||||||
|
stream.on('close', () => resolve(output));
|
||||||
|
stream.on('data', (data: Buffer) => { output += data.toString(); });
|
||||||
|
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContainerVolumes(
|
||||||
|
ip: string,
|
||||||
|
containerName: string,
|
||||||
|
sshUser: string,
|
||||||
|
sshKeyPath?: string,
|
||||||
|
sshPort?: number
|
||||||
|
): Promise<FilesResponse> {
|
||||||
|
try {
|
||||||
|
// Load SSH key
|
||||||
|
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
|
||||||
|
let privateKey: Buffer | undefined;
|
||||||
|
try {
|
||||||
|
privateKey = readFileSync(keyPath);
|
||||||
|
} catch {
|
||||||
|
// If key file doesn't exist, try without it
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConfig: SSHConnectionConfig = {
|
||||||
|
host: ip,
|
||||||
|
port: sshPort || 22,
|
||||||
|
username: sshUser,
|
||||||
|
privateKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const conn = await connectSSH(sshConfig, 30000);
|
||||||
|
|
||||||
|
// Run docker inspect to get mounts
|
||||||
|
const command = `docker inspect ${containerName} --format '{{json .Mounts}}'`;
|
||||||
|
const output = await execSSH(conn, command);
|
||||||
|
|
||||||
|
conn.end();
|
||||||
|
|
||||||
|
// Parse JSON output
|
||||||
|
let mounts: any[] = [];
|
||||||
|
try {
|
||||||
|
mounts = JSON.parse(output.trim()) || [];
|
||||||
|
} catch {
|
||||||
|
// If parsing fails, return empty array
|
||||||
|
return { volumes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to VolumeMount format
|
||||||
|
const volumes: VolumeMount[] = mounts.map((mount: any) => ({
|
||||||
|
source: mount.Source || mount.Source || '',
|
||||||
|
destination: mount.Destination || mount.Destination || '',
|
||||||
|
mode: mount.Mode || mount.Mode || 'rw',
|
||||||
|
})).filter((v: VolumeMount) => v.source && v.destination);
|
||||||
|
|
||||||
|
return { volumes };
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
volumes: [],
|
||||||
|
error: error.message || 'Failed to get container volumes',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/files/:host/:container - Get volume mounts for a container
|
||||||
|
router.get('/files/:host/:container', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { host, container } = req.params;
|
||||||
|
|
||||||
|
const hosts = getHostConfigs();
|
||||||
|
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
||||||
|
|
||||||
|
if (!hostConfig) {
|
||||||
|
const response: FilesResponse = {
|
||||||
|
volumes: [],
|
||||||
|
error: `Host '${host}' not found in configuration`,
|
||||||
|
};
|
||||||
|
return res.status(404).json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getContainerVolumes(
|
||||||
|
hostConfig.ip,
|
||||||
|
container,
|
||||||
|
hostConfig.sshUser,
|
||||||
|
hostConfig.sshKeyPath,
|
||||||
|
hostConfig.sshPort
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : 'Failed to get container volumes';
|
||||||
|
const response: FilesResponse = {
|
||||||
|
volumes: [],
|
||||||
|
error: errMsg,
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory' | 'symlink';
|
||||||
|
size: number;
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowseResponse {
|
||||||
|
path: string;
|
||||||
|
files: FileEntry[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseDirectory(
|
||||||
|
ip: string,
|
||||||
|
path: string,
|
||||||
|
sshUser: string,
|
||||||
|
sshKeyPath?: string,
|
||||||
|
sshPort?: number
|
||||||
|
): Promise<BrowseResponse> {
|
||||||
|
try {
|
||||||
|
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
|
||||||
|
let privateKey: Buffer | undefined;
|
||||||
|
try {
|
||||||
|
privateKey = require('fs').readFileSync(keyPath);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConfig: SSHConnectionConfig = {
|
||||||
|
host: ip,
|
||||||
|
port: sshPort || 22,
|
||||||
|
username: sshUser,
|
||||||
|
privateKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const conn = await connectSSH(sshConfig, 30000);
|
||||||
|
|
||||||
|
const command = `ls -la --time-style=long-iso "${path}" 2>/dev/null | tail -n +2`;
|
||||||
|
const output = await execSSH(conn, command);
|
||||||
|
|
||||||
|
conn.end();
|
||||||
|
|
||||||
|
const files: FileEntry[] = output.trim().split('\n').filter(Boolean).map(line => {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
const perms = parts[0];
|
||||||
|
const size = parseInt(parts[4], 10) || 0;
|
||||||
|
const modified = parts[5] + ' ' + parts[6];
|
||||||
|
const name = parts.slice(8).join(' ');
|
||||||
|
const type = perms.startsWith('d') ? 'directory' :
|
||||||
|
perms.startsWith('l') ? 'symlink' : 'file';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: path === '/' ? `/${name}` : `${path}/${name}`,
|
||||||
|
type,
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { path, files };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : 'Failed to browse directory';
|
||||||
|
return { path, files: [], error: errMsg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/files/browse/:host', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { host } = req.params;
|
||||||
|
const path = (req.query.path as string) || '/';
|
||||||
|
|
||||||
|
const hosts = getHostConfigs();
|
||||||
|
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
||||||
|
|
||||||
|
if (!hostConfig) {
|
||||||
|
return res.status(404).json({ path, files: [], error: `Host '${host}' not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await browseDirectory(
|
||||||
|
hostConfig.ip,
|
||||||
|
path,
|
||||||
|
hostConfig.sshUser,
|
||||||
|
hostConfig.sshKeyPath,
|
||||||
|
hostConfig.sshPort
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : 'Failed to browse directory';
|
||||||
|
res.status(500).json({ path: req.query.path as string || '/', files: [], error: errMsg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
167
server/routes/stats.ts
Normal file
167
server/routes/stats.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* SSH Stats API Endpoint
|
||||||
|
*
|
||||||
|
* GET /api/stats/:host/:container - Get real-time docker stats for a container
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { Client } from 'ssh2';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { getHostConfigs } from '../config';
|
||||||
|
import { StatsResponse } from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
interface SSHConnectionConfig {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
username: string;
|
||||||
|
privateKey?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSSH(config: SSHConnectionConfig, timeout: number = 30000): Promise<Client> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const conn = new Client();
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
conn.end();
|
||||||
|
reject(new Error('Connection timeout'));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
conn.on('ready', () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(conn);
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('error', (err) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.connect({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port || 22,
|
||||||
|
username: config.username,
|
||||||
|
privateKey: config.privateKey,
|
||||||
|
readyTimeout: timeout,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execSSH(conn: Client, command: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
conn.exec(command, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let output = '';
|
||||||
|
stream.on('close', () => resolve(output));
|
||||||
|
stream.on('data', (data: Buffer) => { output += data.toString(); });
|
||||||
|
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContainerStats(
|
||||||
|
hostIp: string,
|
||||||
|
containerName: string,
|
||||||
|
sshUser: string,
|
||||||
|
sshKeyPath?: string,
|
||||||
|
sshPort?: number
|
||||||
|
): Promise<StatsResponse> {
|
||||||
|
// Load SSH key
|
||||||
|
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
|
||||||
|
let privateKey: Buffer | undefined;
|
||||||
|
try {
|
||||||
|
privateKey = readFileSync(keyPath);
|
||||||
|
} catch {
|
||||||
|
// If key file doesn't exist, try without it
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConfig: SSHConnectionConfig = {
|
||||||
|
host: hostIp,
|
||||||
|
port: sshPort || 22,
|
||||||
|
username: sshUser,
|
||||||
|
privateKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const conn = await connectSSH(sshConfig, 30000);
|
||||||
|
|
||||||
|
// Run docker stats with JSON format
|
||||||
|
const dockerStatsCmd = `docker stats ${containerName} --no-stream --format '{"cpu":"{{.CPUPerc}}","mem":"{{.MemPerc}}","net":"{{.NetIO}}"}'`;
|
||||||
|
const output = await execSSH(conn, dockerStatsCmd);
|
||||||
|
|
||||||
|
conn.end();
|
||||||
|
|
||||||
|
// Parse the output
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
|
||||||
|
// Parse CPU (remove % and convert to number)
|
||||||
|
const cpu = parsed.cpu ? parseFloat(parsed.cpu.replace('%', '')) : 0;
|
||||||
|
|
||||||
|
// Parse Memory (remove % and convert to number)
|
||||||
|
const memory = parsed.mem ? parseFloat(parsed.mem.replace('%', '')) : 0;
|
||||||
|
|
||||||
|
// Parse Network I/O (format: "1.2MB / 800kB" -> rx: "1.2MB", tx: "800kB")
|
||||||
|
const netParts = parsed.net ? parsed.net.split(' / ') : ['0B', '0B'];
|
||||||
|
const rx = netParts[0]?.trim() || '0B';
|
||||||
|
const tx = netParts[1]?.trim() || '0B';
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu,
|
||||||
|
memory,
|
||||||
|
network: { rx, tx },
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
return {
|
||||||
|
cpu: 0,
|
||||||
|
memory: 0,
|
||||||
|
network: { rx: '0B', tx: '0B' },
|
||||||
|
error: 'Failed to parse stats output',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/stats/:host/:container
|
||||||
|
router.get('/stats/:host/:container', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { host, container } = req.params;
|
||||||
|
|
||||||
|
// Find host config by name
|
||||||
|
const hosts = getHostConfigs();
|
||||||
|
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
||||||
|
|
||||||
|
if (!hostConfig) {
|
||||||
|
const response: StatsResponse = {
|
||||||
|
cpu: 0,
|
||||||
|
memory: 0,
|
||||||
|
network: { rx: '0B', tx: '0B' },
|
||||||
|
error: `Host '${host}' not found`,
|
||||||
|
};
|
||||||
|
return res.status(404).json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await getContainerStats(
|
||||||
|
hostConfig.ip,
|
||||||
|
container,
|
||||||
|
hostConfig.sshUser,
|
||||||
|
hostConfig.sshKeyPath,
|
||||||
|
hostConfig.sshPort
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error: any) {
|
||||||
|
const response: StatsResponse = {
|
||||||
|
cpu: 0,
|
||||||
|
memory: 0,
|
||||||
|
network: { rx: '0B', tx: '0B' },
|
||||||
|
error: error.message || 'Failed to get stats',
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
60
server/routes/terminal.ts
Normal file
60
server/routes/terminal.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { getHostConfigs } from '../config';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
interface TerminalRequest {
|
||||||
|
host: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/terminal/exec', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { host: hostName, command }: TerminalRequest = req.body;
|
||||||
|
|
||||||
|
if (!hostName || !command) {
|
||||||
|
return res.status(400).json({ error: 'Missing host or command' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hosts = getHostConfigs();
|
||||||
|
const hostConfig = hosts.find(h => h.name === hostName);
|
||||||
|
|
||||||
|
if (!hostConfig) {
|
||||||
|
return res.status(404).json({ error: `Host not found: ${hostName}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyPath = (hostConfig.sshKeyPath || `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
|
||||||
|
const keyArg = `-i ${keyPath}`;
|
||||||
|
const portArg = hostConfig.sshPort && hostConfig.sshPort !== 22 ? `-p ${hostConfig.sshPort}` : '';
|
||||||
|
|
||||||
|
const fullCommand = `ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${hostConfig.sshUser}@${hostConfig.ip} ${command} 2>&1`;
|
||||||
|
|
||||||
|
const output = execSync(fullCommand, { encoding: 'utf-8', timeout: 30000 });
|
||||||
|
|
||||||
|
res.json({ output, error: null });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({
|
||||||
|
output: '',
|
||||||
|
error: error.message || 'Command execution failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/terminal/hosts', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const hosts = getHostConfigs();
|
||||||
|
res.json({
|
||||||
|
hosts: hosts.map(h => ({
|
||||||
|
name: h.name,
|
||||||
|
ip: h.ip,
|
||||||
|
user: h.sshUser
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
82
server/types.ts
Normal file
82
server/types.ts
Normal 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
67
src/AGENTS.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Src (React Frontend)
|
||||||
|
|
||||||
|
**Generated:** 2026-02-19
|
||||||
|
**Location:** src/
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
React 18 + TypeScript frontend with Vite, Tailwind CSS, Zustand state, and React Flow graph visualization.
|
||||||
|
|
||||||
|
## STRUCTURE
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.tsx # React entry point
|
||||||
|
├── App.tsx # Main app component, data loading
|
||||||
|
├── index.css # Tailwind CSS entry
|
||||||
|
├── vite-env.d.ts # Vite type definitions
|
||||||
|
├── components/
|
||||||
|
│ ├── Header.tsx # Top header with refresh
|
||||||
|
│ ├── LeftPanel.tsx # Host list sidebar
|
||||||
|
│ ├── RightPanel.tsx # Details panel (tabs)
|
||||||
|
│ └── Graph/
|
||||||
|
│ └── TopologyGraph.tsx # React Flow graph
|
||||||
|
├── services/
|
||||||
|
│ ├── discovery.ts # Data transformation
|
||||||
|
│ └── sshDiscovery.ts # SSH connection logic
|
||||||
|
├── store/
|
||||||
|
│ └── topologyStore.ts # Zustand global state
|
||||||
|
├── types/
|
||||||
|
│ └── index.ts # TypeScript interfaces
|
||||||
|
├── utils/
|
||||||
|
│ └── colors.ts # Color utilities
|
||||||
|
└── data/
|
||||||
|
└── staticConfig.ts # Static fallback data
|
||||||
|
```
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
|
||||||
|
| Task | File | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| Add global state | src/store/topologyStore.ts | Zustand store |
|
||||||
|
| Modify graph | src/components/Graph/TopologyGraph.tsx | React Flow |
|
||||||
|
| Add new panel | src/components/*.tsx | Follow LeftPanel pattern |
|
||||||
|
|
||||||
|
## STATE MANAGEMENT
|
||||||
|
|
||||||
|
- Zustand store in `src/store/topologyStore.ts`
|
||||||
|
- Keys: nodes, edges, hosts, networkInfo, pollInterval, isLoading, lastUpdated
|
||||||
|
- Panels: leftPanelOpen, rightPanelOpen
|
||||||
|
|
||||||
|
## ANTI-PATTERNS
|
||||||
|
|
||||||
|
- **DO NOT store SSH credentials in localStorage** - security risk
|
||||||
|
- **DO NOT bypass the store** - use setState from useTopologyStore
|
||||||
|
|
||||||
|
## COMMANDS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start Vite dev server (port 3000)
|
||||||
|
npm run build # TypeScript + Vite build to dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
## NOTES
|
||||||
|
|
||||||
|
- API URL: configured via VITE_API_URL env var (default: http://localhost:3001)
|
||||||
|
- Falls back to simulated data if API call fails
|
||||||
|
- Polling interval: configurable via store (default in topologyStore.ts)
|
||||||
327
src/App.tsx
Normal file
327
src/App.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { useEffect, useRef, useCallback, useState, memo } from 'react';
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { io as ioClient, Socket } from 'socket.io-client';
|
||||||
|
import { useTopologyStore } from './store/topologyStore';
|
||||||
|
import {
|
||||||
|
defaultNetworkInfo,
|
||||||
|
discoverHosts,
|
||||||
|
convertToTopology,
|
||||||
|
DiscoveredHost
|
||||||
|
} from './services/discovery';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import LeftPanel from './components/LeftPanel';
|
||||||
|
import RightPanel from './components/RightPanel';
|
||||||
|
import TopologyGraph from './components/Graph/TopologyGraph';
|
||||||
|
import CommandPalette from './components/CommandPalette';
|
||||||
|
import StaleWarning from './components/StaleWarning';
|
||||||
|
import TerminalPanel from './components/TerminalPanel';
|
||||||
|
import MetricsBar from './components/Dashboard/MetricsBar';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
interface ApiHost {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
online: boolean;
|
||||||
|
containers?: string[];
|
||||||
|
services?: string[];
|
||||||
|
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiDiscoveryResponse {
|
||||||
|
hosts: ApiHost[];
|
||||||
|
timestamp: string;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const {
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
setNetworkInfo,
|
||||||
|
setHosts,
|
||||||
|
setLastUpdated,
|
||||||
|
setIsLoading,
|
||||||
|
setDataSource,
|
||||||
|
incrementFailures,
|
||||||
|
resetFailures,
|
||||||
|
setLastSuccessfulDiscovery,
|
||||||
|
} = useTopologyStore(useShallow((s) => ({
|
||||||
|
setNodes: s.setNodes,
|
||||||
|
setEdges: s.setEdges,
|
||||||
|
setNetworkInfo: s.setNetworkInfo,
|
||||||
|
setHosts: s.setHosts,
|
||||||
|
setLastUpdated: s.setLastUpdated,
|
||||||
|
setIsLoading: s.setIsLoading,
|
||||||
|
setDataSource: s.setDataSource,
|
||||||
|
incrementFailures: s.incrementFailures,
|
||||||
|
resetFailures: s.resetFailures,
|
||||||
|
setLastSuccessfulDiscovery: s.setLastSuccessfulDiscovery,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const setConnectionStatus = useTopologyStore((s) => s.setConnectionStatus);
|
||||||
|
|
||||||
|
const {
|
||||||
|
leftPanelOpen,
|
||||||
|
rightPanelOpen,
|
||||||
|
isLoading,
|
||||||
|
pollInterval,
|
||||||
|
terminalOpen,
|
||||||
|
terminalHost,
|
||||||
|
} = useTopologyStore(useShallow((s) => ({
|
||||||
|
leftPanelOpen: s.leftPanelOpen,
|
||||||
|
rightPanelOpen: s.rightPanelOpen,
|
||||||
|
isLoading: s.isLoading,
|
||||||
|
pollInterval: s.pollInterval,
|
||||||
|
terminalOpen: s.terminalOpen,
|
||||||
|
terminalHost: s.terminalHost,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const toggleCommandPalette = useTopologyStore((s) => s.toggleCommandPalette);
|
||||||
|
const closeTerminal = useTopologyStore((s) => s.closeTerminal);
|
||||||
|
|
||||||
|
const isLoadingRef = useRef(isLoading);
|
||||||
|
isLoadingRef.current = isLoading;
|
||||||
|
const pollIntervalRef = useRef(pollInterval);
|
||||||
|
pollIntervalRef.current = pollInterval;
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (isLoadingRef.current) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/discover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: ApiDiscoveryResponse = await response.json();
|
||||||
|
|
||||||
|
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
|
||||||
|
name: h.name,
|
||||||
|
ip: h.ip,
|
||||||
|
online: h.online,
|
||||||
|
containers: (h.containers || []).map((c: string) => ({
|
||||||
|
name: c,
|
||||||
|
image: '',
|
||||||
|
status: 'running',
|
||||||
|
ports: [],
|
||||||
|
created: ''
|
||||||
|
})),
|
||||||
|
services: h.services,
|
||||||
|
vms: h.vms
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { nodes, edges } = convertToTopology(discoveredHosts, defaultNetworkInfo);
|
||||||
|
|
||||||
|
const hosts = discoveredHosts.map(h => ({
|
||||||
|
name: h.name,
|
||||||
|
ip: h.ip,
|
||||||
|
type: (h.name === 'ubuntu' ? 'vm' :
|
||||||
|
h.name === 'proxmox' || h.name === 'truenas' ? 'physical' : 'rpi5') as 'vm' | 'physical' | 'rpi5' | 'container',
|
||||||
|
role: h.name === 'ubuntu' ? 'Primary Docker Host' :
|
||||||
|
h.name === 'grizzley' ? 'Edge Services' :
|
||||||
|
h.name === 'truenas' ? 'Storage (NAS)' :
|
||||||
|
h.name === 'proxmox' ? 'Hypervisor' : 'Host',
|
||||||
|
containers: h.containers.map(c => c.name),
|
||||||
|
services: h.services,
|
||||||
|
vms: h.vms
|
||||||
|
}));
|
||||||
|
|
||||||
|
setNodes(nodes);
|
||||||
|
setEdges(edges);
|
||||||
|
setNetworkInfo(defaultNetworkInfo);
|
||||||
|
setHosts(hosts);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
setDataSource('live');
|
||||||
|
setLastSuccessfulDiscovery(new Date());
|
||||||
|
resetFailures();
|
||||||
|
} else {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Discovery failed, using simulated data:', error);
|
||||||
|
const discoveryResult = await discoverHosts(['ubuntu', 'grizzley', 'truenas', 'ice', 'panda', 'proxmox']);
|
||||||
|
|
||||||
|
const { nodes, edges } = convertToTopology(discoveryResult.hosts, defaultNetworkInfo);
|
||||||
|
|
||||||
|
const hosts = discoveryResult.hosts.map(h => ({
|
||||||
|
name: h.name,
|
||||||
|
ip: h.ip,
|
||||||
|
type: (h.name === 'ubuntu' ? 'vm' :
|
||||||
|
h.name === 'proxmox' || h.name === 'truenas' ? 'physical' : 'rpi5') as 'vm' | 'physical' | 'rpi5' | 'container',
|
||||||
|
role: h.name === 'ubuntu' ? 'Primary Docker Host' :
|
||||||
|
h.name === 'grizzley' ? 'Edge Services' :
|
||||||
|
h.name === 'truenas' ? 'Storage (NAS)' :
|
||||||
|
h.name === 'proxmox' ? 'Hypervisor' : 'Host',
|
||||||
|
containers: h.containers.map(c => c.name)
|
||||||
|
}));
|
||||||
|
|
||||||
|
setNodes(nodes);
|
||||||
|
setEdges(edges);
|
||||||
|
setNetworkInfo(defaultNetworkInfo);
|
||||||
|
setHosts(hosts);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
setDataSource('simulated');
|
||||||
|
incrementFailures();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [setNodes, setEdges, setNetworkInfo, setHosts, setLastUpdated, setIsLoading, setDataSource, incrementFailures, resetFailures, setLastSuccessfulDiscovery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [toggleCommandPalette]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = setInterval(loadData, pollIntervalRef.current);
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// --- WebSocket connection (websocket-engineer skill) ---
|
||||||
|
useEffect(() => {
|
||||||
|
const socket: Socket = ioClient(API_BASE_URL, {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
reconnectionAttempts: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setConnectionStatus('ws');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
setConnectionStatus('polling');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', () => {
|
||||||
|
setConnectionStatus('polling');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for real-time topology updates
|
||||||
|
socket.on('topology:update', (data: ApiDiscoveryResponse) => {
|
||||||
|
if (data?.hosts) {
|
||||||
|
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
|
||||||
|
name: h.name,
|
||||||
|
ip: h.ip,
|
||||||
|
online: h.online,
|
||||||
|
containers: (h.containers || []).map((c: string) => ({
|
||||||
|
name: c, image: '', status: 'running', ports: [], created: ''
|
||||||
|
})),
|
||||||
|
services: h.services,
|
||||||
|
vms: h.vms
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { nodes, edges } = convertToTopology(discoveredHosts, defaultNetworkInfo);
|
||||||
|
setNodes(nodes);
|
||||||
|
setEdges(edges);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [setConnectionStatus, setNodes, setEdges, setLastUpdated]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<div className="h-screen w-screen flex flex-col bg-slate-900">
|
||||||
|
{/* Skip link for accessibility */}
|
||||||
|
<a href="#main-content" className="skip-link">
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<StaleWarning />
|
||||||
|
<Header onRefresh={loadData} isLoading={isLoading} />
|
||||||
|
<MetricsBar />
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden" role="main" id="main-content" tabIndex={-1}>
|
||||||
|
{leftPanelOpen && (
|
||||||
|
<LeftPanel />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<TopologyGraph />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rightPanelOpen && (
|
||||||
|
<RightPanel />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
<CommandPalette />
|
||||||
|
{terminalOpen && terminalHost && (
|
||||||
|
<TerminalPanel host={terminalHost} onClose={closeTerminal} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer = memo(function Footer() {
|
||||||
|
const { lastUpdated, nodes, dataSource, pollInterval } = useTopologyStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
lastUpdated: s.lastUpdated,
|
||||||
|
nodes: s.nodes,
|
||||||
|
dataSource: s.dataSource,
|
||||||
|
pollInterval: s.pollInterval,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const [countdown, setCountdown] = useState(Math.ceil(pollInterval / 1000));
|
||||||
|
const pollIntervalRef = useRef(pollInterval);
|
||||||
|
pollIntervalRef.current = pollInterval;
|
||||||
|
|
||||||
|
const formatTime = (date: Date | null) => {
|
||||||
|
if (!date) return 'Never';
|
||||||
|
return date.toLocaleTimeString();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCountdown(Math.ceil(pollInterval / 1000));
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
setCountdown(prev => {
|
||||||
|
if (prev <= 1) return Math.ceil(pollIntervalRef.current / 1000);
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [lastUpdated, pollInterval]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-8 bg-slate-800 border-t border-slate-700 px-4 flex items-center justify-between text-xs text-slate-400"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<span>Nodes: {nodes.length}</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded ${dataSource === 'live' ? 'bg-green-900 text-green-400' : 'bg-yellow-900 text-yellow-400'}`}>
|
||||||
|
{dataSource === 'live' ? 'Live' : 'Simulated'}
|
||||||
|
</span>
|
||||||
|
<span>Next refresh: {countdown}s</span>
|
||||||
|
<span>Last updated: {formatTime(lastUpdated)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default App;
|
||||||
42
src/components/AGENTS.md
Normal file
42
src/components/AGENTS.md
Normal 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
|
||||||
359
src/components/CommandPalette.tsx
Normal file
359
src/components/CommandPalette.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { Search, Loader2, Network, HardDrive, Box, Database, ArrowLeftRight, ArrowUpDown, Router, Wifi, Monitor, Container, FolderTree, Folder, X } from 'lucide-react';
|
||||||
|
import { useTopologyStore } from '../store/topologyStore';
|
||||||
|
import { NodeType } from '../types';
|
||||||
|
|
||||||
|
interface Command {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
category: 'action' | 'filter' | 'view' | 'orientation';
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypeLabels: Record<NodeType, string> = {
|
||||||
|
gateway: 'Gateway',
|
||||||
|
vlan: 'VLAN',
|
||||||
|
wifi: 'WiFi',
|
||||||
|
host_physical: 'Physical Host',
|
||||||
|
host_vm: 'VM Host',
|
||||||
|
host_container: 'Container Host',
|
||||||
|
vm_lxc: 'LXC Container',
|
||||||
|
vm_qemu: 'QEMU VM',
|
||||||
|
systemd_service: 'Systemd Service',
|
||||||
|
service: 'Service',
|
||||||
|
volume: 'Volume',
|
||||||
|
mount: 'Mount',
|
||||||
|
path: 'Path',
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
||||||
|
gateway: <Router className="w-4 h-4" />,
|
||||||
|
vlan: <Network className="w-4 h-4" />,
|
||||||
|
wifi: <Wifi className="w-4 h-4" />,
|
||||||
|
host_physical: <HardDrive className="w-4 h-4" />,
|
||||||
|
host_vm: <Monitor className="w-4 h-4" />,
|
||||||
|
host_container: <Container className="w-4 h-4" />,
|
||||||
|
vm_lxc: <Container className="w-4 h-4" />,
|
||||||
|
vm_qemu: <Monitor className="w-4 h-4" />,
|
||||||
|
systemd_service: <Box className="w-4 h-4" />,
|
||||||
|
service: <Box className="w-4 h-4" />,
|
||||||
|
volume: <Database className="w-4 h-4" />,
|
||||||
|
mount: <FolderTree className="w-4 h-4" />,
|
||||||
|
path: <Folder className="w-4 h-4" />
|
||||||
|
};
|
||||||
|
|
||||||
|
function fuzzyMatch(pattern: string, text: string): boolean {
|
||||||
|
const patternLower = pattern.toLowerCase();
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
|
||||||
|
let patternIdx = 0;
|
||||||
|
for (let i = 0; i < textLower.length && patternIdx < patternLower.length; i++) {
|
||||||
|
if (textLower[i] === patternLower[patternIdx]) {
|
||||||
|
patternIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patternIdx === patternLower.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
onRefresh?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
|
||||||
|
const {
|
||||||
|
commandPaletteOpen,
|
||||||
|
toggleCommandPalette,
|
||||||
|
typeFilters,
|
||||||
|
toggleTypeFilter,
|
||||||
|
setViewMode,
|
||||||
|
setOrientation
|
||||||
|
} = useTopologyStore();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const commands = useMemo<Command[]>(() => {
|
||||||
|
const cmds: Command[] = [
|
||||||
|
{
|
||||||
|
id: 'refresh',
|
||||||
|
label: 'Refresh Discovery',
|
||||||
|
description: 'Reload topology data from hosts',
|
||||||
|
icon: <Loader2 className="w-4 h-4" />,
|
||||||
|
category: 'action',
|
||||||
|
action: async () => {
|
||||||
|
if (onRefresh) {
|
||||||
|
await onRefresh();
|
||||||
|
}
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'view-full',
|
||||||
|
label: 'Set View: Full',
|
||||||
|
description: 'Show complete topology',
|
||||||
|
icon: <Network className="w-4 h-4" />,
|
||||||
|
category: 'view',
|
||||||
|
action: () => {
|
||||||
|
setViewMode('full');
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'view-network',
|
||||||
|
label: 'Set View: Network',
|
||||||
|
description: 'Show network infrastructure',
|
||||||
|
icon: <Network className="w-4 h-4" />,
|
||||||
|
category: 'view',
|
||||||
|
action: () => {
|
||||||
|
setViewMode('network');
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'view-host',
|
||||||
|
label: 'Set View: Hosts',
|
||||||
|
description: 'Show hosts and containers',
|
||||||
|
icon: <HardDrive className="w-4 h-4" />,
|
||||||
|
category: 'view',
|
||||||
|
action: () => {
|
||||||
|
setViewMode('host');
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'view-service',
|
||||||
|
label: 'Set View: Services',
|
||||||
|
description: 'Show services and volumes',
|
||||||
|
icon: <Box className="w-4 h-4" />,
|
||||||
|
category: 'view',
|
||||||
|
action: () => {
|
||||||
|
setViewMode('service');
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'view-filesystem',
|
||||||
|
label: 'Set View: Files',
|
||||||
|
description: 'Show filesystem hierarchy',
|
||||||
|
icon: <Database className="w-4 h-4" />,
|
||||||
|
category: 'view',
|
||||||
|
action: () => {
|
||||||
|
setViewMode('filesystem');
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orientation-lr',
|
||||||
|
label: 'Set Orientation: Left to Right',
|
||||||
|
icon: <ArrowLeftRight className="w-4 h-4" />,
|
||||||
|
category: 'orientation',
|
||||||
|
action: () => {
|
||||||
|
setOrientation('LR');
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orientation-tb',
|
||||||
|
label: 'Set Orientation: Top to Bottom',
|
||||||
|
icon: <ArrowUpDown className="w-4 h-4" />,
|
||||||
|
category: 'orientation',
|
||||||
|
action: () => {
|
||||||
|
setOrientation('TB');
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const nodeTypes: NodeType[] = [
|
||||||
|
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm',
|
||||||
|
'host_container', 'service', 'volume', 'mount', 'path'
|
||||||
|
];
|
||||||
|
|
||||||
|
nodeTypes.forEach((type) => {
|
||||||
|
const isActive = typeFilters.includes(type);
|
||||||
|
cmds.push({
|
||||||
|
id: `filter-${type}`,
|
||||||
|
label: `${isActive ? 'Disable' : 'Enable'} Filter: ${nodeTypeLabels[type]}`,
|
||||||
|
description: `${isActive ? 'Hide' : 'Show'} ${nodeTypeLabels[type]} nodes`,
|
||||||
|
icon: nodeTypeIcons[type],
|
||||||
|
category: 'filter',
|
||||||
|
action: () => {
|
||||||
|
toggleTypeFilter(type);
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return cmds;
|
||||||
|
}, [typeFilters, onRefresh, setViewMode, setOrientation, toggleTypeFilter, toggleCommandPalette]);
|
||||||
|
|
||||||
|
const filteredCommands = useMemo(() => {
|
||||||
|
if (!search.trim()) return commands;
|
||||||
|
return commands.filter(cmd => fuzzyMatch(search, cmd.label));
|
||||||
|
}, [commands, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [filteredCommands.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (commandPaletteOpen) {
|
||||||
|
setSearch('');
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [commandPaletteOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
const selectedEl = listRef.current.children[selectedIndex] as HTMLElement;
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => Math.min(prev + 1, filteredCommands.length - 1));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredCommands[selectedIndex]) {
|
||||||
|
filteredCommands[selectedIndex].action();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
toggleCommandPalette();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [filteredCommands, selectedIndex, toggleCommandPalette]);
|
||||||
|
|
||||||
|
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
toggleCommandPalette();
|
||||||
|
}
|
||||||
|
}, [toggleCommandPalette]);
|
||||||
|
|
||||||
|
if (!commandPaletteOpen) return null;
|
||||||
|
|
||||||
|
const categoryLabels: Record<Command['category'], string> = {
|
||||||
|
action: 'Actions',
|
||||||
|
filter: 'Filters',
|
||||||
|
view: 'View Mode',
|
||||||
|
orientation: 'Orientation'
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedCommands = filteredCommands.reduce((acc, cmd) => {
|
||||||
|
if (!acc[cmd.category]) {
|
||||||
|
acc[cmd.category] = [];
|
||||||
|
}
|
||||||
|
acc[cmd.category].push(cmd);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<Command['category'], Command[]>);
|
||||||
|
|
||||||
|
let globalIndex = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-xl bg-slate-800 rounded-xl shadow-2xl border border-slate-700 overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-700">
|
||||||
|
<Search className="w-5 h-5 text-slate-400 flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Type a command..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="flex-1 bg-transparent text-white placeholder-slate-400 outline-none text-base"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={toggleCommandPalette}
|
||||||
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-700 rounded"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={listRef} className="max-h-[50vh] overflow-y-auto py-2">
|
||||||
|
{filteredCommands.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-slate-400">
|
||||||
|
No commands found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
Object.entries(groupedCommands).map(([category, cmds]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="px-4 py-2 text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||||
|
{categoryLabels[category as Command['category']]}
|
||||||
|
</div>
|
||||||
|
{cmds.map((cmd) => {
|
||||||
|
const currentIndex = globalIndex++;
|
||||||
|
const isSelected = currentIndex === selectedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cmd.id}
|
||||||
|
onClick={cmd.action}
|
||||||
|
onMouseEnter={() => setSelectedIndex(currentIndex)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-indigo-500/20 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 ${isSelected ? 'text-indigo-400' : 'text-slate-400'}`}>
|
||||||
|
{cmd.icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{cmd.label}</div>
|
||||||
|
{cmd.description && (
|
||||||
|
<div className="text-xs text-slate-500 truncate">{cmd.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="text-xs text-slate-500 flex-shrink-0">
|
||||||
|
Enter
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-2 border-t border-slate-700 flex items-center gap-4 text-xs text-slate-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">↑↓</kbd>
|
||||||
|
Navigate
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Enter</kbd>
|
||||||
|
Select
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Esc</kbd>
|
||||||
|
Close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/Dashboard/HostChart.tsx
Normal file
103
src/components/Dashboard/HostChart.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTopologyStore } from '../../store/topologyStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HostChart — Visual bar chart showing container/service counts per host
|
||||||
|
* (data-visualizer skill — accessible color palette, responsive design)
|
||||||
|
*
|
||||||
|
* Renders a pure-CSS horizontal bar chart — no external charting library needed.
|
||||||
|
* Falls back gracefully when no hosts have containers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Colorblind-safe palette from data-visualizer skill
|
||||||
|
const HOST_COLORS = [
|
||||||
|
'#0066CC', // Blue
|
||||||
|
'#CC6600', // Orange
|
||||||
|
'#7A00CC', // Purple
|
||||||
|
'#00CC66', // Green
|
||||||
|
'#CC0066', // Magenta
|
||||||
|
'#009E73', // Teal
|
||||||
|
'#56B4E9', // Sky Blue
|
||||||
|
'#E69F00', // Amber
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HostChart() {
|
||||||
|
const { nodes } = useTopologyStore(
|
||||||
|
useShallow((s) => ({ nodes: s.nodes }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const hostData = useMemo(() => {
|
||||||
|
// Find host-type nodes
|
||||||
|
const hosts = nodes.filter(
|
||||||
|
(n) =>
|
||||||
|
n.type === 'host_physical' ||
|
||||||
|
n.type === 'host_vm' ||
|
||||||
|
n.type === 'host_container'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count children (services/containers) per host
|
||||||
|
return hosts
|
||||||
|
.map((host) => {
|
||||||
|
const children = nodes.filter((n) => n.data.parentId === host.id);
|
||||||
|
const running = children.filter((n) => n.data.status === 'running').length;
|
||||||
|
const stopped = children.filter((n) => n.data.status === 'stopped').length;
|
||||||
|
return {
|
||||||
|
name: host.name,
|
||||||
|
total: children.length,
|
||||||
|
running,
|
||||||
|
stopped,
|
||||||
|
status: host.data.status,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.total - a.total);
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
const maxCount = Math.max(...hostData.map((h) => h.total), 1);
|
||||||
|
|
||||||
|
if (hostData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-slate-500 text-sm text-center">
|
||||||
|
No host data available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
||||||
|
Services per Host
|
||||||
|
</h3>
|
||||||
|
{hostData.map((host, idx) => (
|
||||||
|
<div key={host.name} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-300 font-medium truncate max-w-[120px]">
|
||||||
|
{host.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
<span className="text-green-400">{host.running}</span>
|
||||||
|
{host.stopped > 0 && (
|
||||||
|
<span className="text-red-400 ml-1">+{host.stopped}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700/50 rounded-full overflow-hidden">
|
||||||
|
{/* Running portion */}
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${(host.total / maxCount) * 100}%`,
|
||||||
|
background: `linear-gradient(90deg, ${HOST_COLORS[idx % HOST_COLORS.length]}CC, ${HOST_COLORS[idx % HOST_COLORS.length]}88)`,
|
||||||
|
}}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={host.total}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={maxCount}
|
||||||
|
aria-label={`${host.name}: ${host.running} running, ${host.stopped} stopped`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/Dashboard/MetricsBar.tsx
Normal file
118
src/components/Dashboard/MetricsBar.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTopologyStore } from '../../store/topologyStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Server,
|
||||||
|
Container,
|
||||||
|
Wifi,
|
||||||
|
Clock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MetricsBar — KPI-style metrics bar (data-visualizer + infrastructure-monitoring skills)
|
||||||
|
* Shows key topology stats: total hosts, running containers, online %, last discovery time.
|
||||||
|
*/
|
||||||
|
export default function MetricsBar() {
|
||||||
|
const { nodes, lastUpdated, connectionStatus } = useTopologyStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
nodes: s.nodes,
|
||||||
|
lastUpdated: s.lastUpdated,
|
||||||
|
connectionStatus: s.connectionStatus,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
const hosts = nodes.filter(
|
||||||
|
(n) =>
|
||||||
|
n.type === 'host_physical' ||
|
||||||
|
n.type === 'host_vm' ||
|
||||||
|
n.type === 'host_container'
|
||||||
|
);
|
||||||
|
const containers = nodes.filter(
|
||||||
|
(n) =>
|
||||||
|
n.type === 'service' ||
|
||||||
|
n.type === 'vm_lxc' ||
|
||||||
|
n.type === 'vm_qemu'
|
||||||
|
);
|
||||||
|
const running = containers.filter((n) => n.data.status === 'running');
|
||||||
|
const onlineHosts = hosts.filter((n) => n.data.status === 'running');
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalHosts: hosts.length,
|
||||||
|
onlineHosts: onlineHosts.length,
|
||||||
|
totalContainers: containers.length,
|
||||||
|
runningContainers: running.length,
|
||||||
|
uptimePercent:
|
||||||
|
hosts.length > 0
|
||||||
|
? Math.round((onlineHosts.length / hosts.length) * 100)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
const connectionColor =
|
||||||
|
connectionStatus === 'ws'
|
||||||
|
? 'text-green-400'
|
||||||
|
: connectionStatus === 'polling'
|
||||||
|
? 'text-yellow-400'
|
||||||
|
: 'text-red-400';
|
||||||
|
|
||||||
|
const connectionLabel =
|
||||||
|
connectionStatus === 'ws'
|
||||||
|
? 'WebSocket'
|
||||||
|
: connectionStatus === 'polling'
|
||||||
|
? 'Polling'
|
||||||
|
: 'Disconnected';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-10 bg-slate-800/80 border-b border-slate-700/50 px-4 flex items-center gap-6 text-xs">
|
||||||
|
{/* Hosts */}
|
||||||
|
<div className="flex items-center gap-1.5" title="Hosts Online">
|
||||||
|
<Server size={13} className="text-emerald-400" />
|
||||||
|
<span className="text-slate-300 font-medium">
|
||||||
|
{metrics.onlineHosts}/{metrics.totalHosts}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">hosts</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Containers */}
|
||||||
|
<div className="flex items-center gap-1.5" title="Running Containers">
|
||||||
|
<Container size={13} className="text-cyan-400" />
|
||||||
|
<span className="text-slate-300 font-medium">
|
||||||
|
{metrics.runningContainers}/{metrics.totalContainers}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">containers</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uptime */}
|
||||||
|
<div className="flex items-center gap-1.5" title="Host Uptime">
|
||||||
|
<Activity size={13} className="text-amber-400" />
|
||||||
|
<span className="text-slate-300 font-medium">
|
||||||
|
{metrics.uptimePercent}%
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">uptime</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Connection status */}
|
||||||
|
<div className="flex items-center gap-1.5" title={`Connection: ${connectionLabel}`}>
|
||||||
|
<Wifi size={13} className={connectionColor} />
|
||||||
|
<span className={`font-medium ${connectionColor}`}>
|
||||||
|
{connectionLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last updated */}
|
||||||
|
{lastUpdated && (
|
||||||
|
<div className="flex items-center gap-1.5" title="Last Discovery">
|
||||||
|
<Clock size={13} className="text-slate-500" />
|
||||||
|
<span className="text-slate-500">
|
||||||
|
{lastUpdated.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/FileBrowser.tsx
Normal file
146
src/components/FileBrowser.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Folder, File, ArrowLeft, X, RefreshCw, Terminal as TerminalIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory' | 'symlink';
|
||||||
|
size: number;
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileBrowserProps {
|
||||||
|
host: string;
|
||||||
|
initialPath?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileBrowser({ host, initialPath = '/', onClose }: FileBrowserProps) {
|
||||||
|
const [currentPath, setCurrentPath] = useState(initialPath);
|
||||||
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchFiles = async (path: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/api/files/browse/${host}?path=${encodeURIComponent(path)}`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
setFiles([]);
|
||||||
|
} else {
|
||||||
|
setFiles(data.files || []);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to fetch files';
|
||||||
|
setError(msg);
|
||||||
|
setFiles([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFiles(currentPath);
|
||||||
|
}, [host, currentPath]);
|
||||||
|
|
||||||
|
const navigateTo = (path: string) => {
|
||||||
|
setCurrentPath(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goUp = () => {
|
||||||
|
const parts = currentPath.split('/').filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/');
|
||||||
|
navigateTo(newPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes}B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileIcon = (type: string) => {
|
||||||
|
if (type === 'directory') return <Folder className="w-4 h-4 text-amber-400" />;
|
||||||
|
if (type === 'symlink') return <TerminalIcon className="w-4 h-4 text-blue-400" />;
|
||||||
|
return <File className="w-4 h-4 text-slate-400" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">/</span>
|
||||||
|
<span className="text-cyan-400 font-medium">{host}</span>
|
||||||
|
<span className="text-slate-500">:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currentPath}
|
||||||
|
onChange={(e) => setCurrentPath(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && navigateTo(e.currentTarget.value)}
|
||||||
|
className="bg-slate-700 text-slate-200 px-2 py-1 rounded text-sm font-mono w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => fetchFiles(currentPath)}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<RefreshCw className="w-6 h-6 text-slate-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-red-400 p-4">{error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="font-mono text-sm">
|
||||||
|
{currentPath !== '/' && (
|
||||||
|
<button
|
||||||
|
onClick={goUp}
|
||||||
|
className="flex items-center gap-2 w-full px-2 py-1 hover:bg-slate-800 rounded"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-400">..</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{files.map((file) => (
|
||||||
|
<button
|
||||||
|
key={file.path}
|
||||||
|
onClick={() => file.type === 'directory' && navigateTo(file.path)}
|
||||||
|
className={`flex items-center gap-2 w-full px-2 py-1 hover:bg-slate-800 rounded ${
|
||||||
|
file.type !== 'directory' ? 'cursor-default' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getFileIcon(file.type)}
|
||||||
|
<span className="text-slate-200 flex-1 text-left">{file.name}</span>
|
||||||
|
<span className="text-slate-500 text-xs">{formatSize(file.size)}</span>
|
||||||
|
<span className="text-slate-600 text-xs w-24">{file.modified}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
src/components/Graph/TopologyGraph.tsx
Normal file
249
src/components/Graph/TopologyGraph.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { useCallback, useMemo, memo } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
Node,
|
||||||
|
Edge,
|
||||||
|
NodeProps,
|
||||||
|
Handle,
|
||||||
|
Position,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import dagre from 'dagre';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { useTopologyStore } from '../../store/topologyStore';
|
||||||
|
import { getNodeColor, getStatusColor } from '../../utils/colors';
|
||||||
|
import { Server, Network, Wifi, Box, Database, Folder } from 'lucide-react';
|
||||||
|
import { NodeType, ServiceCategory } from '../../types';
|
||||||
|
|
||||||
|
const nodeIcons: Record<NodeType, React.ReactNode> = {
|
||||||
|
gateway: <Network className="w-5 h-5" />,
|
||||||
|
vlan: <Network className="w-4 h-4" />,
|
||||||
|
wifi: <Wifi className="w-4 h-4" />,
|
||||||
|
host_physical: <Server className="w-5 h-5" />,
|
||||||
|
host_vm: <Server className="w-5 h-5" />,
|
||||||
|
host_container: <Server className="w-5 h-5" />,
|
||||||
|
vm_lxc: <Box className="w-4 h-4" />,
|
||||||
|
vm_qemu: <Server className="w-5 h-5" />,
|
||||||
|
systemd_service: <Box className="w-4 h-4" />,
|
||||||
|
service: <Box className="w-4 h-4" />,
|
||||||
|
volume: <Database className="w-4 h-4" />,
|
||||||
|
mount: <Database className="w-4 h-4" />,
|
||||||
|
path: <Folder className="w-4 h-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomNode = memo(function CustomNode({ data, selected, id }: NodeProps) {
|
||||||
|
const highlightPath = useTopologyStore((s) => s.highlightPath);
|
||||||
|
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string };
|
||||||
|
const nodeColor = getNodeColor(nodeData.type || 'service', nodeData.category);
|
||||||
|
const statusColor = getStatusColor(nodeData.status || 'unknown');
|
||||||
|
const isHighlighted = highlightPath.includes(id);
|
||||||
|
const isDimmed = highlightPath.length > 0 && !isHighlighted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 ${selected
|
||||||
|
? 'border-sky-400 shadow-lg shadow-sky-400/20 scale-[1.02]'
|
||||||
|
: isHighlighted
|
||||||
|
? 'border-indigo-400 shadow-lg shadow-indigo-400/20'
|
||||||
|
: 'border-slate-600 hover:border-slate-500 hover:shadow-md hover:shadow-slate-700/30 hover:scale-[1.01]'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDimmed ? '#0F172A' : '#1E293B',
|
||||||
|
minWidth: '140px',
|
||||||
|
opacity: isDimmed ? 0.4 : 1
|
||||||
|
}}
|
||||||
|
role="treeitem"
|
||||||
|
aria-label={`${nodeData.label || 'Node'}, ${nodeData.type?.replace(/_/g, ' ') || 'unknown type'}, ${nodeData.status || 'unknown status'}`}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Left} className="!bg-slate-400" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `${nodeColor}20` }}
|
||||||
|
>
|
||||||
|
<div style={{ color: nodeColor }} aria-hidden="true">
|
||||||
|
{nodeIcons[nodeData.type || 'service']}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-white truncate">
|
||||||
|
{nodeData.label}
|
||||||
|
</div>
|
||||||
|
{nodeData.ip && (
|
||||||
|
<div className="text-xs text-slate-500 font-mono">
|
||||||
|
{nodeData.ip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-2.5 h-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
aria-label={`Status: ${nodeData.status || 'unknown'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle type="source" position={Position.Right} className="!bg-slate-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
custom: CustomNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeWidth = 180;
|
||||||
|
const nodeHeight = 70;
|
||||||
|
|
||||||
|
function getLayoutedElements(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
|
||||||
|
if (nodes.length === 0) return { nodes: [], edges: [] };
|
||||||
|
|
||||||
|
const dagreGraph = new dagre.graphlib.Graph();
|
||||||
|
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||||
|
dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 100 });
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||||
|
});
|
||||||
|
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
dagreGraph.setEdge(edge.source, edge.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
dagre.layout(dagreGraph);
|
||||||
|
|
||||||
|
const layoutedNodes = nodes.map((node) => {
|
||||||
|
const nodeWithPosition = dagreGraph.node(node.id);
|
||||||
|
if (!nodeWithPosition) return node;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: {
|
||||||
|
x: nodeWithPosition.x - nodeWidth / 2,
|
||||||
|
y: nodeWithPosition.y - nodeHeight / 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: layoutedNodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopologyGraph() {
|
||||||
|
const {
|
||||||
|
edges: storeEdges,
|
||||||
|
selectedNodeId,
|
||||||
|
setSelectedNode,
|
||||||
|
getFilteredNodes,
|
||||||
|
orientation,
|
||||||
|
viewMode,
|
||||||
|
highlightPath
|
||||||
|
} = useTopologyStore(useShallow((s) => ({
|
||||||
|
edges: s.edges,
|
||||||
|
selectedNodeId: s.selectedNodeId,
|
||||||
|
setSelectedNode: s.setSelectedNode,
|
||||||
|
getFilteredNodes: s.getFilteredNodes,
|
||||||
|
orientation: s.orientation,
|
||||||
|
viewMode: s.viewMode,
|
||||||
|
highlightPath: s.highlightPath,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Memoize the layout computation instead of useState + useEffect
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
const filteredNodes = getFilteredNodes();
|
||||||
|
|
||||||
|
if (filteredNodes.length === 0) {
|
||||||
|
return { nodes: [] as Node[], edges: [] as Edge[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeIds = new Set(filteredNodes.map(n => n.id));
|
||||||
|
|
||||||
|
const newNodes: Node[] = filteredNodes.map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
type: 'custom',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: node.name,
|
||||||
|
type: node.type,
|
||||||
|
status: node.data.status,
|
||||||
|
category: node.data.category,
|
||||||
|
ip: node.data.ip,
|
||||||
|
},
|
||||||
|
selected: node.id === selectedNodeId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newEdges: Edge[] = storeEdges
|
||||||
|
.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
||||||
|
.map(edge => {
|
||||||
|
const isPathEdge = highlightPath.includes(edge.source) && highlightPath.includes(edge.target);
|
||||||
|
const isSelected = edge.source === selectedNodeId || edge.target === selectedNodeId;
|
||||||
|
return {
|
||||||
|
id: edge.id,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
type: 'smoothstep',
|
||||||
|
animated: isSelected || isPathEdge,
|
||||||
|
style: {
|
||||||
|
stroke: isSelected
|
||||||
|
? '#38BDF8'
|
||||||
|
: isPathEdge
|
||||||
|
? '#818CF8'
|
||||||
|
: '#475569',
|
||||||
|
strokeWidth: isSelected || isPathEdge
|
||||||
|
? 2
|
||||||
|
: 1,
|
||||||
|
opacity: highlightPath.length > 0 && !isPathEdge ? 0.3 : 1
|
||||||
|
},
|
||||||
|
markerEnd: {
|
||||||
|
type: 'arrowclosed' as const,
|
||||||
|
color: isSelected
|
||||||
|
? '#38BDF8'
|
||||||
|
: isPathEdge
|
||||||
|
? '#818CF8'
|
||||||
|
: '#475569',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return getLayoutedElements(newNodes, newEdges, orientation);
|
||||||
|
}, [getFilteredNodes, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]);
|
||||||
|
|
||||||
|
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||||
|
setSelectedNode(node.id);
|
||||||
|
}, [setSelectedNode]);
|
||||||
|
|
||||||
|
const onPaneClick = useCallback(() => {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}, [setSelectedNode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full" role="application" aria-label="Network topology graph">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
onPaneClick={onPaneClick}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
minZoom={0.1}
|
||||||
|
maxZoom={2}
|
||||||
|
defaultEdgeOptions={{
|
||||||
|
type: 'smoothstep',
|
||||||
|
}}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background color="#334155" gap={20} size={1} />
|
||||||
|
<Controls className="!bg-slate-700 !border-slate-600 !rounded-lg !shadow-lg" />
|
||||||
|
<MiniMap
|
||||||
|
className="!bg-slate-800 !border-slate-700"
|
||||||
|
nodeColor={(node) => getNodeColor(node.data?.type as NodeType || 'service', (node.data?.category as ServiceCategory) || undefined)}
|
||||||
|
maskColor="rgba(15, 23, 42, 0.8)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
src/components/Header.tsx
Normal file
244
src/components/Header.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Search, Loader2, Network, HardDrive, Box, Database, Link, ArrowLeftRight, ArrowUpDown, Router, Wifi, Monitor, Container, FolderTree, Folder, Settings } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { useTopologyStore, Orientation, StatusFilter } from '../store/topologyStore';
|
||||||
|
import { ViewMode, NodeType } from '../types';
|
||||||
|
import { getNodeColor } from '../utils/colors';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onRefresh?: () => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewModes: { mode: ViewMode; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ mode: 'full', label: 'Full', icon: <Link className="w-4 h-4" /> },
|
||||||
|
{ mode: 'network', label: 'Network', icon: <Network className="w-4 h-4" /> },
|
||||||
|
{ mode: 'host', label: 'Hosts', icon: <HardDrive className="w-4 h-4" /> },
|
||||||
|
{ mode: 'service', label: 'Services', icon: <Box className="w-4 h-4" /> },
|
||||||
|
{ mode: 'filesystem', label: 'Files', icon: <Database className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const orientations: { value: Orientation; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ value: 'LR', label: 'Left to Right', icon: <ArrowLeftRight className="w-4 h-4" /> },
|
||||||
|
{ value: 'TB', label: 'Top to Bottom', icon: <ArrowUpDown className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const nodeTypeFilters: { type: NodeType; icon: React.ReactNode }[] = [
|
||||||
|
{ type: 'gateway', icon: <Router className="w-4 h-4" /> },
|
||||||
|
{ type: 'vlan', icon: <Network className="w-4 h-4" /> },
|
||||||
|
{ type: 'wifi', icon: <Wifi className="w-4 h-4" /> },
|
||||||
|
{ type: 'host_physical', icon: <HardDrive className="w-4 h-4" /> },
|
||||||
|
{ type: 'host_vm', icon: <Monitor className="w-4 h-4" /> },
|
||||||
|
{ type: 'host_container', icon: <Container className="w-4 h-4" /> },
|
||||||
|
{ type: 'service', icon: <Box className="w-4 h-4" /> },
|
||||||
|
{ type: 'volume', icon: <Database className="w-4 h-4" /> },
|
||||||
|
{ type: 'mount', icon: <FolderTree className="w-4 h-4" /> },
|
||||||
|
{ type: 'path', icon: <Folder className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Header({ onRefresh, isLoading: externalLoading }: HeaderProps) {
|
||||||
|
const {
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
orientation,
|
||||||
|
setOrientation,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
typeFilters,
|
||||||
|
toggleTypeFilter,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
toggleLeftPanel,
|
||||||
|
toggleRightPanel,
|
||||||
|
leftPanelOpen,
|
||||||
|
rightPanelOpen,
|
||||||
|
isLoading: storeLoading,
|
||||||
|
pollInterval,
|
||||||
|
setPollInterval
|
||||||
|
} = useTopologyStore(useShallow((s) => ({
|
||||||
|
viewMode: s.viewMode,
|
||||||
|
setViewMode: s.setViewMode,
|
||||||
|
orientation: s.orientation,
|
||||||
|
setOrientation: s.setOrientation,
|
||||||
|
searchQuery: s.searchQuery,
|
||||||
|
setSearchQuery: s.setSearchQuery,
|
||||||
|
typeFilters: s.typeFilters,
|
||||||
|
toggleTypeFilter: s.toggleTypeFilter,
|
||||||
|
statusFilter: s.statusFilter,
|
||||||
|
setStatusFilter: s.setStatusFilter,
|
||||||
|
toggleLeftPanel: s.toggleLeftPanel,
|
||||||
|
toggleRightPanel: s.toggleRightPanel,
|
||||||
|
leftPanelOpen: s.leftPanelOpen,
|
||||||
|
rightPanelOpen: s.rightPanelOpen,
|
||||||
|
isLoading: s.isLoading,
|
||||||
|
pollInterval: s.pollInterval,
|
||||||
|
setPollInterval: s.setPollInterval,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const loading = externalLoading ?? storeLoading;
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
if (onRefresh) {
|
||||||
|
await onRefresh();
|
||||||
|
}
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-14 bg-slate-800 border-b border-slate-700 px-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-indigo-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Network className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-semibold text-white">Homelab Topology</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-slate-600" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1" role="toolbar" aria-label="View mode">
|
||||||
|
{viewModes.map(({ mode, label, icon }) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => setViewMode(mode)}
|
||||||
|
aria-pressed={viewMode === mode}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${viewMode === mode
|
||||||
|
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/50'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-slate-600" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="orientation-select" className="visually-hidden">Graph orientation</label>
|
||||||
|
<select
|
||||||
|
id="orientation-select"
|
||||||
|
value={orientation}
|
||||||
|
onChange={(e) => setOrientation(e.target.value as Orientation)}
|
||||||
|
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
{orientations.map(({ value, label }) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-slate-600" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1" role="toolbar" aria-label="Node type filters">
|
||||||
|
{nodeTypeFilters.map(({ type, icon }) => {
|
||||||
|
const isActive = typeFilters.includes(type);
|
||||||
|
const color = getNodeColor(type);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => toggleTypeFilter(type)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
aria-label={`Filter ${type.replace(/_/g, ' ')}`}
|
||||||
|
className={`p-2 rounded-md transition-colors ${isActive
|
||||||
|
? 'border'
|
||||||
|
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
style={isActive ? {
|
||||||
|
backgroundColor: `${color}20`,
|
||||||
|
borderColor: `${color}50`,
|
||||||
|
color: color
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-slate-600" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="status-filter" className="visually-hidden">Status filter</label>
|
||||||
|
<select
|
||||||
|
id="status-filter"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||||
|
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="running">Running</option>
|
||||||
|
<option value="stopped">Stopped</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-slate-600" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4 text-slate-400" aria-hidden="true" />
|
||||||
|
<label htmlFor="poll-interval" className="visually-hidden">Poll interval</label>
|
||||||
|
<select
|
||||||
|
id="poll-interval"
|
||||||
|
value={pollInterval}
|
||||||
|
onChange={(e) => setPollInterval(parseInt(e.target.value, 10))}
|
||||||
|
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value={10000}>10 seconds</option>
|
||||||
|
<option value={30000}>30 seconds</option>
|
||||||
|
<option value={60000}>1 minute</option>
|
||||||
|
<option value={300000}>5 minutes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" aria-hidden="true" />
|
||||||
|
<label htmlFor="node-search" className="visually-hidden">Search nodes</label>
|
||||||
|
<input
|
||||||
|
id="node-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search nodes..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-64 h-9 pl-9 pr-4 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label={loading ? 'Loading data' : 'Refresh data'}
|
||||||
|
className="h-9 px-3 flex items-center gap-2 bg-slate-700 hover:bg-slate-600 border border-slate-600 rounded-lg text-sm text-slate-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Loader2 className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} aria-hidden="true" />
|
||||||
|
{loading ? 'Loading...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-slate-600" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleLeftPanel}
|
||||||
|
aria-label={leftPanelOpen ? 'Hide child nodes panel' : 'Show child nodes panel'}
|
||||||
|
aria-pressed={leftPanelOpen}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${leftPanelOpen ? 'bg-indigo-500/20 text-indigo-400' : 'text-slate-400 hover:text-white hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Box className="w-5 h-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleRightPanel}
|
||||||
|
aria-label={rightPanelOpen ? 'Hide details panel' : 'Show details panel'}
|
||||||
|
aria-pressed={rightPanelOpen}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${rightPanelOpen ? 'bg-indigo-500/20 text-indigo-400' : 'text-slate-400 hover:text-white hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Database className="w-5 h-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/LeftPanel.tsx
Normal file
127
src/components/LeftPanel.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { ChevronRight, Server, Network, Wifi, Box, Database, Folder } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { useTopologyStore } from '../store/topologyStore';
|
||||||
|
import { getNodeColor } from '../utils/colors';
|
||||||
|
import { TopologyNode, NodeType } from '../types';
|
||||||
|
import HostChart from './Dashboard/HostChart';
|
||||||
|
|
||||||
|
const typeIcons: Record<NodeType, React.ReactNode> = {
|
||||||
|
gateway: <Network className="w-4 h-4" />,
|
||||||
|
vlan: <Network className="w-4 h-4" />,
|
||||||
|
wifi: <Wifi className="w-4 h-4" />,
|
||||||
|
host_physical: <Server className="w-4 h-4" />,
|
||||||
|
host_vm: <Server className="w-4 h-4" />,
|
||||||
|
host_container: <Server className="w-4 h-4" />,
|
||||||
|
vm_lxc: <Box className="w-4 h-4" />,
|
||||||
|
vm_qemu: <Server className="w-4 h-4" />,
|
||||||
|
systemd_service: <Box className="w-4 h-4" />,
|
||||||
|
service: <Box className="w-4 h-4" />,
|
||||||
|
volume: <Database className="w-4 h-4" />,
|
||||||
|
mount: <Database className="w-4 h-4" />,
|
||||||
|
path: <Folder className="w-4 h-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabels: Record<NodeType, string> = {
|
||||||
|
gateway: 'Gateway',
|
||||||
|
vlan: 'VLAN',
|
||||||
|
wifi: 'WiFi',
|
||||||
|
host_physical: 'Physical',
|
||||||
|
host_vm: 'VM',
|
||||||
|
host_container: 'Container',
|
||||||
|
vm_lxc: 'LXC',
|
||||||
|
vm_qemu: 'QEMU',
|
||||||
|
systemd_service: 'Systemd',
|
||||||
|
service: 'Service',
|
||||||
|
volume: 'Volume',
|
||||||
|
mount: 'Mount',
|
||||||
|
path: 'Path',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeftPanel() {
|
||||||
|
const { nodes, selectedNodeId, setSelectedNode } = useTopologyStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
nodes: s.nodes,
|
||||||
|
selectedNodeId: s.selectedNodeId,
|
||||||
|
setSelectedNode: s.setSelectedNode,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
||||||
|
const childNodes = nodes.filter(n => n.data.parentId === selectedNodeId);
|
||||||
|
|
||||||
|
const groupedChildren = useMemo(() => {
|
||||||
|
return childNodes.reduce((acc, node) => {
|
||||||
|
if (!acc[node.type]) acc[node.type] = [];
|
||||||
|
acc[node.type].push(node);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<NodeType, TopologyNode[]>);
|
||||||
|
}, [childNodes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-72 bg-slate-800 border-r border-slate-700 flex flex-col" aria-label="Child nodes panel">
|
||||||
|
<div className="h-12 px-4 flex items-center border-b border-slate-700">
|
||||||
|
<h2 className="text-sm font-semibold text-white uppercase tracking-wide">
|
||||||
|
{selectedNode ? 'Child Nodes' : 'Select a Node'}
|
||||||
|
</h2>
|
||||||
|
{childNodes.length > 0 && (
|
||||||
|
<span className="ml-2 px-2 py-0.5 bg-slate-700 text-slate-300 text-xs rounded-full">
|
||||||
|
{childNodes.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{!selectedNode ? (
|
||||||
|
<div className="p-4 text-center text-slate-500 text-sm">
|
||||||
|
Click on a node to view its child nodes
|
||||||
|
</div>
|
||||||
|
) : childNodes.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-slate-500 text-sm">
|
||||||
|
No child nodes
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<nav className="p-2" aria-label="Child node list">
|
||||||
|
{Object.entries(groupedChildren).map(([type, typeNodes]) => (
|
||||||
|
<div key={type} className="mb-3">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||||
|
{typeLabels[type as NodeType]}s ({typeNodes.length})
|
||||||
|
</div>
|
||||||
|
{typeNodes.map(node => (
|
||||||
|
<button
|
||||||
|
key={node.id}
|
||||||
|
onClick={() => setSelectedNode(node.id)}
|
||||||
|
className={`w-full px-3 py-2 flex items-center gap-3 rounded-lg transition-colors text-left ${selectedNodeId === node.id
|
||||||
|
? 'bg-indigo-500/20 text-indigo-300'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `${getNodeColor(node.type, node.data.category)}20` }}
|
||||||
|
>
|
||||||
|
<div style={{ color: getNodeColor(node.type, node.data.category) }}>
|
||||||
|
{typeIcons[node.type]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{node.name}</div>
|
||||||
|
{node.data.ip && (
|
||||||
|
<div className="text-xs text-slate-500">{node.data.ip}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-slate-500" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Host metrics chart (data-visualizer skill) */}
|
||||||
|
<div className="border-t border-slate-700/50">
|
||||||
|
<HostChart />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
332
src/components/RightPanel.tsx
Normal file
332
src/components/RightPanel.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { useTopologyStore } from '../store/topologyStore';
|
||||||
|
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors';
|
||||||
|
import { TopologyNode } from '../types';
|
||||||
|
import FileBrowser from './FileBrowser';
|
||||||
|
|
||||||
|
type TabId = 'details' | 'config' | 'files' | 'usage' | 'importance';
|
||||||
|
|
||||||
|
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: 'details', label: 'Details', icon: <Info className="w-4 h-4" /> },
|
||||||
|
{ id: 'config', label: 'Config', icon: <FileCode className="w-4 h-4" /> },
|
||||||
|
{ id: 'files', label: 'Files', icon: <FolderOpen className="w-4 h-4" /> },
|
||||||
|
{ id: 'usage', label: 'Usage', icon: <BarChart3 className="w-4 h-4" /> },
|
||||||
|
{ id: 'importance', label: 'Importance', icon: <Star className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RightPanel() {
|
||||||
|
const { nodes, selectedNodeId, setSelectedNode, openTerminal } = useTopologyStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
nodes: s.nodes,
|
||||||
|
selectedNodeId: s.selectedNodeId,
|
||||||
|
setSelectedNode: s.setSelectedNode,
|
||||||
|
openTerminal: s.openTerminal,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('details');
|
||||||
|
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
||||||
|
|
||||||
|
const handleTabKeyDown = useCallback((e: React.KeyboardEvent, currentIndex: number) => {
|
||||||
|
let newIndex = currentIndex;
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
newIndex = (currentIndex + 1) % tabs.length;
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
newIndex = 0;
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
newIndex = tabs.length - 1;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab(tabs[newIndex].id);
|
||||||
|
// Focus the new tab button
|
||||||
|
const tabEl = document.getElementById(`tab-${tabs[newIndex].id}`);
|
||||||
|
tabEl?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!selectedNode) {
|
||||||
|
return (
|
||||||
|
<aside className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col items-center justify-center p-4" aria-label="Node details panel">
|
||||||
|
<div className="text-slate-500 text-sm text-center">
|
||||||
|
Select a node to view its details
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeColor = getNodeColor(selectedNode.type, selectedNode.data.category);
|
||||||
|
const statusColor = getStatusColor(selectedNode.data.status);
|
||||||
|
const isHost = selectedNode.type.startsWith('host_') || selectedNode.type.startsWith('vm_');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col" aria-label="Node details panel">
|
||||||
|
<div className="h-12 px-4 flex items-center justify-between border-b border-slate-700">
|
||||||
|
<h2 className="text-sm font-semibold text-white truncate">{selectedNode.name}</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isHost && selectedNodeId && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setFileBrowserOpen(true)}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
aria-label="Browse files"
|
||||||
|
>
|
||||||
|
<Folder className="w-4 h-4 text-slate-400" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openTerminal(selectedNodeId)}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
aria-label="Open terminal"
|
||||||
|
>
|
||||||
|
<Terminal className="w-4 h-4 text-slate-400" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedNode(null)}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
aria-label="Close details panel"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-slate-400" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex border-b border-slate-700" role="tablist" aria-label="Node information tabs">
|
||||||
|
{tabs.map((tab, index) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
id={`tab-${tab.id}`}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.id}
|
||||||
|
aria-controls={`tabpanel-${tab.id}`}
|
||||||
|
tabIndex={activeTab === tab.id ? 0 : -1}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1 py-3 text-xs transition-colors ${activeTab === tab.id
|
||||||
|
? 'text-indigo-400 border-b-2 border-indigo-400 bg-indigo-500/10'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{tab.icon}</span>
|
||||||
|
<span className="visually-hidden">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto p-4"
|
||||||
|
role="tabpanel"
|
||||||
|
id={`tabpanel-${activeTab}`}
|
||||||
|
aria-labelledby={`tab-${activeTab}`}
|
||||||
|
>
|
||||||
|
{activeTab === 'details' && <DetailsTab node={selectedNode} nodeColor={nodeColor} statusColor={statusColor} />}
|
||||||
|
{activeTab === 'config' && <ConfigTab node={selectedNode} />}
|
||||||
|
{activeTab === 'files' && <FilesTab node={selectedNode} />}
|
||||||
|
{activeTab === 'usage' && <UsageTab node={selectedNode} />}
|
||||||
|
{activeTab === 'importance' && <ImportanceTab node={selectedNode} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fileBrowserOpen && selectedNodeId && (
|
||||||
|
<FileBrowser
|
||||||
|
host={selectedNodeId}
|
||||||
|
onClose={() => setFileBrowserOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailsTab({ node, nodeColor, statusColor }: { node: TopologyNode; nodeColor: string; statusColor: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `${nodeColor}20` }}
|
||||||
|
>
|
||||||
|
<div style={{ color: nodeColor }} className="text-lg font-bold">
|
||||||
|
{node.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">{node.type.replace(/_/g, ' ')}</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400 capitalize">{node.data.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1">IP Address</div>
|
||||||
|
<div className="font-mono text-sm text-white">{node.data.ip || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.data.description && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1">Description</div>
|
||||||
|
<div className="text-sm text-slate-300">{node.data.description}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1">Metadata</div>
|
||||||
|
<div className="bg-slate-900 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto">
|
||||||
|
{JSON.stringify(node.data.metadata, null, 2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigTab({ node }: { node: TopologyNode }) {
|
||||||
|
const hasConfig = node.data.config;
|
||||||
|
|
||||||
|
if (!hasConfig) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-slate-500 py-8">
|
||||||
|
<FileCode className="w-8 h-8 mx-auto mb-2 opacity-50" aria-hidden="true" />
|
||||||
|
<div className="text-sm">No configuration available</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<pre className="bg-slate-900 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto">
|
||||||
|
{node.data.config}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilesTab({ node }: { node: TopologyNode }) {
|
||||||
|
const files = node.data.files || [
|
||||||
|
'/etc/docker-compose.yml',
|
||||||
|
'/etc/traefik/dynamic.yml',
|
||||||
|
'/var/log/container.log'
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{files.map((file: string, idx: number) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
className="w-full px-3 py-2 flex items-center gap-2 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-left transition-colors"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4 text-slate-400" aria-hidden="true" />
|
||||||
|
<span className="font-mono text-xs text-slate-300 truncate">{file}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageTab({ node }: { node: TopologyNode }) {
|
||||||
|
const isService = node.type === 'service';
|
||||||
|
|
||||||
|
if (!isService) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-slate-500 py-8">
|
||||||
|
<BarChart3 className="w-8 h-8 mx-auto mb-2 opacity-50" aria-hidden="true" />
|
||||||
|
<div className="text-sm">Usage data available for services only</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-slate-400">CPU</span>
|
||||||
|
<span className="text-white">12.4%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700 rounded-full overflow-hidden" role="progressbar" aria-valuenow={12.4} aria-valuemin={0} aria-valuemax={100} aria-label="CPU usage">
|
||||||
|
<div className="h-full w-[12.4%] bg-indigo-500 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-slate-400">Memory</span>
|
||||||
|
<span className="text-white">256 MB / 1 GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700 rounded-full overflow-hidden" role="progressbar" aria-valuenow={25.6} aria-valuemin={0} aria-valuemax={100} aria-label="Memory usage">
|
||||||
|
<div className="h-full w-[25.6%] bg-purple-500 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-slate-400">Network I/O</span>
|
||||||
|
<span className="text-white">1.2 MB/s ↓ 0.8 MB/s ↑</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700 rounded-full overflow-hidden" role="progressbar" aria-valuenow={40} aria-valuemin={0} aria-valuemax={100} aria-label="Network I/O">
|
||||||
|
<div className="h-full w-[40%] bg-cyan-500 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportanceTab({ node }: { node: TopologyNode }) {
|
||||||
|
const importance = node.data.importance || 3;
|
||||||
|
const importanceLabel = getImportanceLabel(importance);
|
||||||
|
const importanceColor = getImportanceColor(importance);
|
||||||
|
|
||||||
|
const reasons: Record<number, string[]> = {
|
||||||
|
5: ['Critical infrastructure', 'Single point of failure', 'Required for other services'],
|
||||||
|
4: ['Important service', 'Used frequently', 'Difficult to replace'],
|
||||||
|
3: ['Standard service', 'Can be rebuilt', 'Not critical'],
|
||||||
|
2: ['Optional service', 'Rarely used', 'Easy to recreate'],
|
||||||
|
1: ['Development only', 'Non-critical', 'Can be disabled'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-8 h-8 ${star <= importance ? 'fill-yellow-500 text-yellow-500' : 'text-slate-600'}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="visually-hidden">Importance: {importance} out of 5 stars</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-semibold" style={{ color: importanceColor }}>
|
||||||
|
{importanceLabel}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-400">Importance Level {importance}/5</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-700/50 rounded-lg p-3">
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Why this level?</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{reasons[importance]?.map((reason, idx) => (
|
||||||
|
<li key={idx} className="text-sm text-slate-300 flex items-center gap-2">
|
||||||
|
<div className="w-1 h-1 bg-slate-500 rounded-full" aria-hidden="true" />
|
||||||
|
{reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/StaleWarning.tsx
Normal file
41
src/components/StaleWarning.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/TerminalPanel.tsx
Normal file
127
src/components/TerminalPanel.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
interface TerminalProps {
|
||||||
|
host: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TerminalPanel({ host, onClose }: TerminalProps) {
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalInstance = useRef<Terminal | null>(null);
|
||||||
|
const inputBuffer = useRef('');
|
||||||
|
const [currentPath, _setCurrentPath] = useState('~');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!terminalRef.current) return;
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
theme: {
|
||||||
|
background: '#0f172a',
|
||||||
|
foreground: '#e2e8f0',
|
||||||
|
cursor: '#22d3ee',
|
||||||
|
cursorAccent: '#0f172a',
|
||||||
|
selectionBackground: '#334155',
|
||||||
|
},
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
cursorBlink: true,
|
||||||
|
scrollback: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(terminalRef.current);
|
||||||
|
fit.fit();
|
||||||
|
|
||||||
|
terminalInstance.current = term;
|
||||||
|
|
||||||
|
term.writeln('\x1b[36m╔════════════════════════════════════════════════╗\x1b[0m');
|
||||||
|
term.writeln('\x1b[36m║ Homelab Topology - SSH Terminal ║\x1b[0m');
|
||||||
|
term.writeln('\x1b[36m╚════════════════════════════════════════════════╝\x1b[0m');
|
||||||
|
term.writeln(`\x1b[32mConnecting to ${host}...\x1b[0m`);
|
||||||
|
term.writeln('');
|
||||||
|
|
||||||
|
const executeCommand = async (cmd: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/terminal/exec`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ host, command: cmd }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.output) {
|
||||||
|
term.writeln(data.output);
|
||||||
|
}
|
||||||
|
if (data.error) {
|
||||||
|
term.writeln(`\x1b[31mError: ${data.error}\x1b[0m`);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
term.writeln(`\x1b[31mConnection error: ${msg}\x1b[0m`);
|
||||||
|
}
|
||||||
|
term.write(`\x1b[32m${host}:\x1b[0m${currentPath}$ `);
|
||||||
|
};
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
const code = data.charCodeAt(0);
|
||||||
|
|
||||||
|
if (code === 13) {
|
||||||
|
term.writeln('');
|
||||||
|
if (inputBuffer.current.trim()) {
|
||||||
|
executeCommand(inputBuffer.current);
|
||||||
|
} else {
|
||||||
|
term.write(`\x1b[32m${host}:\x1b[0m${currentPath}$ `);
|
||||||
|
}
|
||||||
|
inputBuffer.current = '';
|
||||||
|
} else if (code === 127) {
|
||||||
|
if (inputBuffer.current.length > 0) {
|
||||||
|
inputBuffer.current = inputBuffer.current.slice(0, -1);
|
||||||
|
term.write('\b \b');
|
||||||
|
}
|
||||||
|
} else if (code < 32) {
|
||||||
|
// skip control chars
|
||||||
|
} else {
|
||||||
|
inputBuffer.current += data;
|
||||||
|
term.write(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
term.write(`\x1b[32m${host}:\x1b[0m${currentPath}$ `);
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
fit.fit();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
term.dispose();
|
||||||
|
};
|
||||||
|
}, [host, currentPath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-cyan-400 font-mono">$</span>
|
||||||
|
<span className="text-slate-200 font-medium">{host}</span>
|
||||||
|
<span className="text-slate-500">:</span>
|
||||||
|
<span className="text-slate-400 font-mono">{currentPath}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={terminalRef} className="flex-1 p-2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/data/staticConfig.ts
Normal file
275
src/data/staticConfig.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { TopologyNode, TopologyEdge, NetworkInfo, Host, ServiceCategory } from '../types';
|
||||||
|
|
||||||
|
const serviceCategory: Record<string, ServiceCategory> = {
|
||||||
|
jellyfin: 'media',
|
||||||
|
immich: 'media',
|
||||||
|
sonarr: 'media',
|
||||||
|
radarr: 'media',
|
||||||
|
sabnzbd: 'media',
|
||||||
|
qbittorrent: 'media',
|
||||||
|
lidarr: 'media',
|
||||||
|
readarr: 'media',
|
||||||
|
bazarr: 'media',
|
||||||
|
tdarr: 'media',
|
||||||
|
traefik: 'infra',
|
||||||
|
authentik: 'infra',
|
||||||
|
vaultwarden: 'infra',
|
||||||
|
gitea: 'infra',
|
||||||
|
postgres: 'infra',
|
||||||
|
portainer: 'infra',
|
||||||
|
prometheus: 'monitoring',
|
||||||
|
grafana: 'monitoring',
|
||||||
|
loki: 'monitoring',
|
||||||
|
uptimekuma: 'monitoring',
|
||||||
|
cadvisor: 'monitoring',
|
||||||
|
nodeexporter: 'monitoring',
|
||||||
|
alertmanager: 'monitoring',
|
||||||
|
litellm: 'ai',
|
||||||
|
ollama: 'ai',
|
||||||
|
'codeserver-ai': 'ai',
|
||||||
|
qdrant: 'storage',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCategory(name: string): ServiceCategory {
|
||||||
|
const key = Object.keys(serviceCategory).find(k => name.toLowerCase().includes(k));
|
||||||
|
return serviceCategory[key || ''] || 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const staticNetworkInfo: NetworkInfo = {
|
||||||
|
gateway: {
|
||||||
|
model: 'UniFi Dream Machine Pro',
|
||||||
|
ip: '192.168.1.1'
|
||||||
|
},
|
||||||
|
vlans: [
|
||||||
|
{ id: 1, name: 'Default', subnet: '192.168.1.0/24', purpose: 'Core infrastructure' },
|
||||||
|
{ id: 3, name: 'Trusted', subnet: '192.168.3.0/24', purpose: 'Trusted devices' },
|
||||||
|
{ id: 10, name: 'Family', subnet: '192.168.10.0/24', purpose: 'Family devices' },
|
||||||
|
{ id: 20, name: 'Guest', subnet: '192.168.20.0/24', purpose: 'Guest network' },
|
||||||
|
{ id: 30, name: 'IoT', subnet: '192.168.30.0/24', purpose: 'IoT devices, Home Assistant' },
|
||||||
|
{ id: 50, name: 'Production', subnet: '192.168.50.0/24', purpose: 'Production services' }
|
||||||
|
],
|
||||||
|
wifi: [
|
||||||
|
{ ssid: 'Will of D.', vlan: 'default' },
|
||||||
|
{ ssid: 'Will of D. IoT', vlan: 30 },
|
||||||
|
{ ssid: 'Family of D.', vlan: 10 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staticHosts: Host[] = [
|
||||||
|
{
|
||||||
|
name: 'ubuntu',
|
||||||
|
ip: '192.168.50.61',
|
||||||
|
type: 'vm',
|
||||||
|
role: 'Primary Docker Host',
|
||||||
|
containers: ['traefik', 'jellyfin', 'immich', 'authentik', 'gitea', 'prometheus', 'grafana', 'sonarr', 'radarr', 'sabnzbd', 'qbittorrent', 'lidarr', 'readarr', 'bazarr', 'tdarr', 'portainer', 'vaultwarden', 'loki', 'uptimekuma', 'cadvisor', 'nodeexporter', 'alertmanager', 'ollama', 'litellm', 'codeserver-ai', 'glance', 'gotify', 'prowlarr', 'jellyseerr', 'jellystat', 'jellysweep', 'navidrome', 'flaresolverr', 'gluetun', 'crowdsec', 'postgres-shared', 'immich_postgres', 'immich_redis', 'immich_server', 'immich_machine_learning', 'filebrowser', 'dockge', 'jfa-go', 'it-tools', 'bentopdf', 'maintainerr']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'grizzley',
|
||||||
|
ip: '192.168.50.84',
|
||||||
|
type: 'rpi5',
|
||||||
|
role: 'Edge Services',
|
||||||
|
containers: ['traefik', 'frigate', 'scrypted', 'cloudflared']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ice',
|
||||||
|
ip: '192.168.50.197',
|
||||||
|
type: 'rpi5',
|
||||||
|
role: 'Spare/Development',
|
||||||
|
containers: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'panda',
|
||||||
|
ip: '192.168.30.196',
|
||||||
|
type: 'rpi5',
|
||||||
|
role: 'Home Assistant',
|
||||||
|
containers: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'truenas',
|
||||||
|
ip: '192.168.50.12',
|
||||||
|
type: 'physical',
|
||||||
|
role: 'Storage (NAS)',
|
||||||
|
containers: ['qdrant']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'proxmox',
|
||||||
|
ip: '192.168.50.11',
|
||||||
|
type: 'physical',
|
||||||
|
role: 'Hypervisor',
|
||||||
|
containers: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const containerDetails: Record<string, { description: string; ports?: string[]; importance: 1|2|3|4|5 }> = {
|
||||||
|
traefik: { description: 'Reverse proxy and load balancer', ports: ['80', '443'], importance: 5 },
|
||||||
|
jellyfin: { description: 'Media server', ports: ['8096', '9090'], importance: 5 },
|
||||||
|
immich: { description: 'Photo and video management', importance: 4 },
|
||||||
|
authentik: { description: 'Identity provider and SSO', importance: 5 },
|
||||||
|
gitea: { description: 'Self-hosted Git service', ports: ['3000', '2222'], importance: 4 },
|
||||||
|
prometheus: { description: 'Monitoring and metrics', ports: ['9090'], importance: 4 },
|
||||||
|
grafana: { description: 'Metrics visualization', ports: ['3000'], importance: 4 },
|
||||||
|
sonarr: { description: 'TV show management', importance: 4 },
|
||||||
|
radarr: { description: 'Movie management', importance: 4 },
|
||||||
|
tdarr: { description: 'Video transcoding', importance: 3 },
|
||||||
|
frigate: { description: 'NVR with local AI', importance: 4 },
|
||||||
|
vaultwarden: { description: 'Password manager', importance: 5 },
|
||||||
|
portainer: { description: 'Container management UI', ports: ['9000', '9443'], importance: 3 },
|
||||||
|
ollama: { description: 'Local LLM runtime', importance: 4 },
|
||||||
|
litellm: { description: 'LLM API gateway', ports: ['4000'], importance: 4 },
|
||||||
|
codeserver: { description: 'Browser-based VS Code', ports: ['8443'], importance: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function createNodesFromData(): TopologyNode[] {
|
||||||
|
const nodes: TopologyNode[] = [];
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: 'gateway',
|
||||||
|
type: 'gateway',
|
||||||
|
name: 'UniFi Gateway',
|
||||||
|
data: {
|
||||||
|
status: 'running',
|
||||||
|
metadata: { model: 'UniFi Dream Machine Pro', ip: '192.168.1.1' },
|
||||||
|
importance: 5,
|
||||||
|
description: 'Main network gateway and firewall'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
staticNetworkInfo.vlans.forEach((vlan) => {
|
||||||
|
nodes.push({
|
||||||
|
id: `vlan-${vlan.id}`,
|
||||||
|
type: 'vlan',
|
||||||
|
name: `VLAN ${vlan.id}: ${vlan.name}`,
|
||||||
|
data: {
|
||||||
|
status: 'running',
|
||||||
|
metadata: { subnet: vlan.subnet, purpose: vlan.purpose },
|
||||||
|
importance: 4,
|
||||||
|
description: vlan.purpose || '',
|
||||||
|
parentId: 'gateway'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
staticNetworkInfo.wifi.forEach((wifi) => {
|
||||||
|
nodes.push({
|
||||||
|
id: `wifi-${wifi.ssid.replace(/\s+/g, '-')}`,
|
||||||
|
type: 'wifi',
|
||||||
|
name: wifi.ssid,
|
||||||
|
data: {
|
||||||
|
status: 'running',
|
||||||
|
metadata: { vlan: wifi.vlan },
|
||||||
|
importance: 3,
|
||||||
|
parentId: 'gateway'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostTypeMap: Record<string, 'host_physical' | 'host_vm' | 'host_container'> = {
|
||||||
|
physical: 'host_physical',
|
||||||
|
vm: 'host_vm',
|
||||||
|
rpi5: 'host_container',
|
||||||
|
container: 'host_container'
|
||||||
|
};
|
||||||
|
|
||||||
|
staticHosts.forEach((host) => {
|
||||||
|
const hostNode: TopologyNode = {
|
||||||
|
id: host.name,
|
||||||
|
type: hostTypeMap[host.type] || 'host_physical',
|
||||||
|
name: `${host.name} (${host.ip})`,
|
||||||
|
data: {
|
||||||
|
ip: host.ip,
|
||||||
|
status: 'running',
|
||||||
|
metadata: { role: host.role, type: host.type, containerCount: host.containers.length },
|
||||||
|
importance: host.role.includes('Primary') ? 5 : 4,
|
||||||
|
description: host.role,
|
||||||
|
parentId: 'vlan-50'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
nodes.push(hostNode);
|
||||||
|
|
||||||
|
host.containers.forEach((container) => {
|
||||||
|
const details = containerDetails[container.replace(/-/g, '')] || { description: container, importance: 3 };
|
||||||
|
const portStr = details.ports ? details.ports.join(', ') : undefined;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: `${host.name}-${container}`,
|
||||||
|
type: 'service',
|
||||||
|
name: container,
|
||||||
|
data: {
|
||||||
|
status: 'running',
|
||||||
|
metadata: {
|
||||||
|
host: host.name,
|
||||||
|
image: `${container}:latest`,
|
||||||
|
ports: portStr
|
||||||
|
},
|
||||||
|
category: getCategory(container),
|
||||||
|
importance: details.importance,
|
||||||
|
description: details.description,
|
||||||
|
parentId: host.name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: 'truenas-nfs',
|
||||||
|
type: 'mount',
|
||||||
|
name: '/mnt/truenas/media',
|
||||||
|
data: {
|
||||||
|
status: 'running',
|
||||||
|
metadata: { type: 'nfs', server: '192.168.50.12' },
|
||||||
|
importance: 5,
|
||||||
|
description: 'TrueNAS NFS mount for media storage',
|
||||||
|
parentId: 'truenas'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
['/movies', '/tv', '/music', '/photos'].forEach((path) => {
|
||||||
|
nodes.push({
|
||||||
|
id: `path-${path.replace(/\//g, '-')}`,
|
||||||
|
type: 'path',
|
||||||
|
name: path,
|
||||||
|
data: {
|
||||||
|
status: 'running',
|
||||||
|
metadata: { type: 'filesystem' },
|
||||||
|
importance: 4,
|
||||||
|
parentId: 'truenas-nfs'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEdgesFromData(): TopologyEdge[] {
|
||||||
|
const edges: TopologyEdge[] = [];
|
||||||
|
|
||||||
|
edges.push({ id: 'e-gateway-vlan50', source: 'gateway', target: 'vlan-50' });
|
||||||
|
|
||||||
|
staticNetworkInfo.vlans.forEach((vlan) => {
|
||||||
|
if (vlan.id !== 50) {
|
||||||
|
edges.push({ id: `e-gateway-vlan${vlan.id}`, source: 'gateway', target: `vlan-${vlan.id}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
staticNetworkInfo.wifi.forEach((wifi) => {
|
||||||
|
edges.push({ id: `e-gateway-wifi-${wifi.ssid.replace(/\s+/g, '-')}`, source: 'gateway', target: `wifi-${wifi.ssid.replace(/\s+/g, '-')}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
staticHosts.forEach((host) => {
|
||||||
|
edges.push({ id: `e-vlan50-${host.name}`, source: 'vlan-50', target: host.name });
|
||||||
|
|
||||||
|
host.containers.forEach((container) => {
|
||||||
|
edges.push({ id: `e-${host.name}-${container}`, source: host.name, target: `${host.name}-${container}` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
edges.push({ id: 'e-truenas-nfs', source: 'truenas', target: 'truenas-nfs' });
|
||||||
|
['/movies', '/tv', '/music', '/photos'].forEach((path) => {
|
||||||
|
edges.push({ id: `e-nfs-${path.replace(/\//g, '-')}`, source: 'truenas-nfs', target: `path-${path.replace(/\//g, '-')}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialNodes = createNodesFromData();
|
||||||
|
export const initialEdges = createEdgesFromData();
|
||||||
131
src/index.css
Normal file
131
src/index.css
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0F172A;
|
||||||
|
--bg-secondary: #1E293B;
|
||||||
|
--bg-tertiary: #334155;
|
||||||
|
--text-primary: #F8FAFC;
|
||||||
|
--text-secondary: #94A3B8;
|
||||||
|
--border: #475569;
|
||||||
|
--accent: #38BDF8;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skip link (a11y) ────────────────────────────────────── */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
padding: 8px 16px;
|
||||||
|
z-index: 100;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: top 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus-visible styles (a11y) ─────────────────────────── */
|
||||||
|
:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.4);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reduced motion (a11y) ───────────────────────────────── */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__node {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── React Flow overrides ────────────────────────────────── */
|
||||||
|
.react-flow__node {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge-path {
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__minimap {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls styling */
|
||||||
|
.react-flow__controls {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button {
|
||||||
|
background-color: #334155;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #94A3B8;
|
||||||
|
border-bottom: 1px solid #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button:hover {
|
||||||
|
background-color: #475569;
|
||||||
|
color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button:active {
|
||||||
|
background-color: #1E293B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Visually hidden utility (a11y) ──────────────────────── */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal 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
388
src/services/discovery.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
133
src/services/sshDiscovery.ts
Normal file
133
src/services/sshDiscovery.ts
Normal 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);
|
||||||
|
}
|
||||||
146
src/store/topologyStore.test.ts
Normal file
146
src/store/topologyStore.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, test, expect, afterEach } from 'vitest';
|
||||||
|
import { useTopologyStore } from './topologyStore';
|
||||||
|
import { TopologyNode } from '../types';
|
||||||
|
|
||||||
|
const mockNodes: TopologyNode[] = [
|
||||||
|
{
|
||||||
|
id: 'gateway-1',
|
||||||
|
name: 'Gateway',
|
||||||
|
type: 'gateway',
|
||||||
|
data: {
|
||||||
|
status: 'running',
|
||||||
|
ip: '10.0.0.1',
|
||||||
|
parentId: undefined,
|
||||||
|
importance: 5,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'host-1',
|
||||||
|
name: 'Ubuntu',
|
||||||
|
type: 'host_vm',
|
||||||
|
data: {
|
||||||
|
status: 'running',
|
||||||
|
ip: '10.0.0.10',
|
||||||
|
parentId: 'gateway-1',
|
||||||
|
importance: 4,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'service-1',
|
||||||
|
name: 'Traefik',
|
||||||
|
type: 'service',
|
||||||
|
data: {
|
||||||
|
status: 'running',
|
||||||
|
ip: '10.0.0.10',
|
||||||
|
parentId: 'host-1',
|
||||||
|
category: 'infra',
|
||||||
|
importance: 5,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'service-2',
|
||||||
|
name: 'Plex',
|
||||||
|
type: 'service',
|
||||||
|
data: {
|
||||||
|
status: 'stopped',
|
||||||
|
ip: '10.0.0.10',
|
||||||
|
parentId: 'host-1',
|
||||||
|
category: 'media',
|
||||||
|
importance: 2,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('topologyStore', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset store state between tests
|
||||||
|
useTopologyStore.setState({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
selectedNodeId: null,
|
||||||
|
searchQuery: '',
|
||||||
|
typeFilters: [],
|
||||||
|
statusFilter: 'all',
|
||||||
|
highlightPath: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
* Basic state management
|
||||||
|
* ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe('setNodes', () => {
|
||||||
|
test('sets nodes correctly', () => {
|
||||||
|
useTopologyStore.getState().setNodes(mockNodes);
|
||||||
|
expect(useTopologyStore.getState().nodes).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setSelectedNode', () => {
|
||||||
|
test('selects a node by id', () => {
|
||||||
|
useTopologyStore.getState().setNodes(mockNodes);
|
||||||
|
useTopologyStore.getState().setSelectedNode('host-1');
|
||||||
|
expect(useTopologyStore.getState().selectedNodeId).toBe('host-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears selection with null', () => {
|
||||||
|
useTopologyStore.getState().setSelectedNode('host-1');
|
||||||
|
useTopologyStore.getState().setSelectedNode(null);
|
||||||
|
expect(useTopologyStore.getState().selectedNodeId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
* Filtering
|
||||||
|
* ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe('getFilteredNodes', () => {
|
||||||
|
test('returns all nodes when no filters active', () => {
|
||||||
|
useTopologyStore.getState().setNodes(mockNodes);
|
||||||
|
const filtered = useTopologyStore.getState().getFilteredNodes();
|
||||||
|
expect(filtered).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by search query', () => {
|
||||||
|
useTopologyStore.getState().setNodes(mockNodes);
|
||||||
|
useTopologyStore.getState().setSearchQuery('traefik');
|
||||||
|
const filtered = useTopologyStore.getState().getFilteredNodes();
|
||||||
|
expect(filtered.some(n => n.name === 'Traefik')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by status', () => {
|
||||||
|
useTopologyStore.getState().setNodes(mockNodes);
|
||||||
|
useTopologyStore.getState().setStatusFilter('stopped');
|
||||||
|
const filtered = useTopologyStore.getState().getFilteredNodes();
|
||||||
|
expect(filtered.every(n => n.data.status === 'stopped')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by type', () => {
|
||||||
|
useTopologyStore.getState().setNodes(mockNodes);
|
||||||
|
useTopologyStore.getState().toggleTypeFilter('service');
|
||||||
|
const filtered = useTopologyStore.getState().getFilteredNodes();
|
||||||
|
expect(filtered.every(n => n.type === 'service')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
* Type filter toggle
|
||||||
|
* ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe('toggleTypeFilter', () => {
|
||||||
|
test('adds type when not present', () => {
|
||||||
|
useTopologyStore.getState().toggleTypeFilter('gateway');
|
||||||
|
expect(useTopologyStore.getState().typeFilters).toContain('gateway');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes type when already present', () => {
|
||||||
|
useTopologyStore.getState().toggleTypeFilter('gateway');
|
||||||
|
useTopologyStore.getState().toggleTypeFilter('gateway');
|
||||||
|
expect(useTopologyStore.getState().typeFilters).not.toContain('gateway');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
246
src/store/topologyStore.ts
Normal file
246
src/store/topologyStore.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage, devtools } from 'zustand/middleware';
|
||||||
|
import { ViewMode, TopologyNode, TopologyEdge, NetworkInfo, Host, NodeType } from '../types';
|
||||||
|
|
||||||
|
export type Orientation = 'LR' | 'TB';
|
||||||
|
export type StatusFilter = 'all' | 'running' | 'stopped';
|
||||||
|
|
||||||
|
// All node types for default filter
|
||||||
|
const ALL_NODE_TYPES: NodeType[] = [
|
||||||
|
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container',
|
||||||
|
'vm_lxc', 'vm_qemu', 'systemd_service', 'service', 'volume', 'mount', 'path'
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TopologyState {
|
||||||
|
nodes: TopologyNode[];
|
||||||
|
edges: TopologyEdge[];
|
||||||
|
selectedNodeId: string | null;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
orientation: Orientation;
|
||||||
|
searchQuery: string;
|
||||||
|
typeFilters: NodeType[];
|
||||||
|
statusFilter: StatusFilter;
|
||||||
|
leftPanelOpen: boolean;
|
||||||
|
rightPanelOpen: boolean;
|
||||||
|
lastUpdated: Date | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
pollInterval: number;
|
||||||
|
|
||||||
|
networkInfo: NetworkInfo | null;
|
||||||
|
hosts: Host[];
|
||||||
|
|
||||||
|
dataSource: 'live' | 'simulated';
|
||||||
|
consecutiveFailures: number;
|
||||||
|
lastSuccessfulDiscovery: Date | null;
|
||||||
|
|
||||||
|
commandPaletteOpen: boolean;
|
||||||
|
terminalOpen: boolean;
|
||||||
|
terminalHost: string | null;
|
||||||
|
highlightPath: string[];
|
||||||
|
connectionStatus: 'ws' | 'polling' | 'disconnected';
|
||||||
|
staleWarningDismissed: boolean;
|
||||||
|
|
||||||
|
setConnectionStatus: (status: 'ws' | 'polling' | 'disconnected') => void;
|
||||||
|
setNodes: (nodes: TopologyNode[]) => void;
|
||||||
|
setEdges: (edges: TopologyEdge[]) => void;
|
||||||
|
setSelectedNode: (nodeId: string | null) => void;
|
||||||
|
setViewMode: (mode: ViewMode) => void;
|
||||||
|
setOrientation: (orientation: Orientation) => void;
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
toggleTypeFilter: (type: NodeType) => void;
|
||||||
|
setStatusFilter: (filter: StatusFilter) => void;
|
||||||
|
toggleLeftPanel: () => void;
|
||||||
|
toggleRightPanel: () => void;
|
||||||
|
setLastUpdated: (date: Date) => void;
|
||||||
|
openTerminal: (host: string) => void;
|
||||||
|
closeTerminal: () => void;
|
||||||
|
setIsLoading: (loading: boolean) => void;
|
||||||
|
setPollInterval: (interval: number) => void;
|
||||||
|
setNetworkInfo: (info: NetworkInfo) => void;
|
||||||
|
setHosts: (hosts: Host[]) => void;
|
||||||
|
setDataSource: (source: 'live' | 'simulated') => void;
|
||||||
|
incrementFailures: () => void;
|
||||||
|
resetFailures: () => void;
|
||||||
|
setLastSuccessfulDiscovery: (date: Date) => void;
|
||||||
|
toggleCommandPalette: () => void;
|
||||||
|
setHighlightPath: (ids: string[]) => void;
|
||||||
|
dismissStaleWarning: () => void;
|
||||||
|
|
||||||
|
getSelectedNode: () => TopologyNode | null;
|
||||||
|
getChildNodes: () => TopologyNode[];
|
||||||
|
getFilteredNodes: () => TopologyNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTopologyStore = create<TopologyState>()(
|
||||||
|
devtools(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
selectedNodeId: null,
|
||||||
|
viewMode: 'full',
|
||||||
|
orientation: 'LR',
|
||||||
|
searchQuery: '',
|
||||||
|
typeFilters: ALL_NODE_TYPES,
|
||||||
|
statusFilter: 'all',
|
||||||
|
leftPanelOpen: true,
|
||||||
|
rightPanelOpen: true,
|
||||||
|
lastUpdated: null,
|
||||||
|
isLoading: false,
|
||||||
|
pollInterval: 30000,
|
||||||
|
networkInfo: null,
|
||||||
|
hosts: [],
|
||||||
|
dataSource: 'simulated',
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
lastSuccessfulDiscovery: null,
|
||||||
|
commandPaletteOpen: false,
|
||||||
|
terminalOpen: false,
|
||||||
|
terminalHost: null,
|
||||||
|
highlightPath: [],
|
||||||
|
connectionStatus: 'polling',
|
||||||
|
staleWarningDismissed: false,
|
||||||
|
|
||||||
|
setNodes: (nodes) => set({ nodes }),
|
||||||
|
setEdges: (edges) => set({ edges }),
|
||||||
|
setSelectedNode: (nodeId) => {
|
||||||
|
if (!nodeId) {
|
||||||
|
set({ selectedNodeId: nodeId, highlightPath: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = get();
|
||||||
|
const path: string[] = [nodeId];
|
||||||
|
let currentNode = state.nodes.find(n => n.id === nodeId);
|
||||||
|
while (currentNode?.data?.parentId) {
|
||||||
|
path.push(currentNode.data.parentId);
|
||||||
|
currentNode = state.nodes.find(n => n.id === currentNode?.data?.parentId);
|
||||||
|
}
|
||||||
|
set({ selectedNodeId: nodeId, highlightPath: path });
|
||||||
|
},
|
||||||
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
|
setOrientation: (orientation) => set({ orientation }),
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
toggleTypeFilter: (type) => set((state) => {
|
||||||
|
const exists = state.typeFilters.includes(type);
|
||||||
|
return {
|
||||||
|
typeFilters: exists
|
||||||
|
? state.typeFilters.filter(t => t !== type)
|
||||||
|
: [...state.typeFilters, type]
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
setStatusFilter: (filter) => set({ statusFilter: filter }),
|
||||||
|
toggleLeftPanel: () => set((state) => ({ leftPanelOpen: !state.leftPanelOpen })),
|
||||||
|
toggleRightPanel: () => set((state) => ({ rightPanelOpen: !state.rightPanelOpen })),
|
||||||
|
setLastUpdated: (date) => set({ lastUpdated: date }),
|
||||||
|
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
setPollInterval: (interval) => set({ pollInterval: interval }),
|
||||||
|
setNetworkInfo: (info) => set({ networkInfo: info }),
|
||||||
|
setHosts: (hosts) => set({ hosts }),
|
||||||
|
setDataSource: (source) => set({ dataSource: source }),
|
||||||
|
incrementFailures: () => set((state) => ({ consecutiveFailures: state.consecutiveFailures + 1 })),
|
||||||
|
resetFailures: () => set({ consecutiveFailures: 0 }),
|
||||||
|
setLastSuccessfulDiscovery: (date) => set({ lastSuccessfulDiscovery: date }),
|
||||||
|
toggleCommandPalette: () => set((state) => ({ commandPaletteOpen: !state.commandPaletteOpen })),
|
||||||
|
setHighlightPath: (ids) => set({ highlightPath: ids }),
|
||||||
|
setConnectionStatus: (status) => set({ connectionStatus: status }),
|
||||||
|
dismissStaleWarning: () => set({ staleWarningDismissed: true }),
|
||||||
|
openTerminal: (host) => set({ terminalOpen: true, terminalHost: host }),
|
||||||
|
closeTerminal: () => set({ terminalOpen: false, terminalHost: null }),
|
||||||
|
|
||||||
|
getSelectedNode: () => {
|
||||||
|
const { nodes, selectedNodeId } = get();
|
||||||
|
return nodes.find(n => n.id === selectedNodeId) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getChildNodes: () => {
|
||||||
|
const { nodes, selectedNodeId } = get();
|
||||||
|
if (!selectedNodeId) return [];
|
||||||
|
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
||||||
|
if (!selectedNode) return [];
|
||||||
|
return nodes.filter(n => n.data.parentId === selectedNodeId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilteredNodes: () => {
|
||||||
|
const { nodes, viewMode, searchQuery, typeFilters, statusFilter } = get();
|
||||||
|
|
||||||
|
let filtered = nodes;
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(n =>
|
||||||
|
n.name.toLowerCase().includes(query) ||
|
||||||
|
n.data.ip?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(n => n.data.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFilters.length > 0 && typeFilters.length < ALL_NODE_TYPES.length) {
|
||||||
|
filtered = filtered.filter(n => typeFilters.includes(n.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowedTypes: NodeType[] = [];
|
||||||
|
if (viewMode === 'network') {
|
||||||
|
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
|
||||||
|
} else if (viewMode === 'host') {
|
||||||
|
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
|
||||||
|
} else if (viewMode === 'service') {
|
||||||
|
allowedTypes = ['host_physical', 'host_vm', 'host_container', 'service', 'volume'];
|
||||||
|
} else if (viewMode === 'filesystem') {
|
||||||
|
allowedTypes = ['volume', 'mount', 'path'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedTypes.length > 0) {
|
||||||
|
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||||
|
const includeSet = new Set<string>();
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (allowedTypes.includes(node.type)) {
|
||||||
|
includeSet.add(node.id);
|
||||||
|
|
||||||
|
let current: TopologyNode | undefined = node;
|
||||||
|
while (current?.data?.parentId) {
|
||||||
|
const parentId = current.data.parentId;
|
||||||
|
includeSet.add(parentId);
|
||||||
|
current = nodeMap.get(parentId);
|
||||||
|
if (!current) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered = filtered.filter(n => includeSet.has(n.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'homelab-topology-settings',
|
||||||
|
version: 2,
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state: TopologyState) => ({
|
||||||
|
viewMode: state.viewMode,
|
||||||
|
orientation: state.orientation,
|
||||||
|
searchQuery: state.searchQuery,
|
||||||
|
typeFilters: state.typeFilters,
|
||||||
|
statusFilter: state.statusFilter,
|
||||||
|
leftPanelOpen: state.leftPanelOpen,
|
||||||
|
rightPanelOpen: state.rightPanelOpen,
|
||||||
|
pollInterval: state.pollInterval
|
||||||
|
})
|
||||||
|
}), { name: 'TopologyStore' }));
|
||||||
|
|
||||||
|
// Focused selector hooks to avoid unnecessary re-renders
|
||||||
|
export const useSelectedNode = () => useTopologyStore((s) => {
|
||||||
|
const { nodes, selectedNodeId } = s;
|
||||||
|
return nodes.find(n => n.id === selectedNodeId) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useChildNodes = () => useTopologyStore((s) => {
|
||||||
|
const { nodes, selectedNodeId } = s;
|
||||||
|
if (!selectedNodeId) return [];
|
||||||
|
return nodes.filter(n => n.data.parentId === selectedNodeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useFilteredNodes = () => useTopologyStore((s) => s.getFilteredNodes());
|
||||||
140
src/types/index.ts
Normal file
140
src/types/index.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
export type NodeType =
|
||||||
|
| 'gateway'
|
||||||
|
| 'vlan'
|
||||||
|
| 'wifi'
|
||||||
|
| 'host_physical'
|
||||||
|
| 'host_vm'
|
||||||
|
| 'host_container'
|
||||||
|
| 'vm_lxc'
|
||||||
|
| 'vm_qemu'
|
||||||
|
| 'systemd_service'
|
||||||
|
| 'service'
|
||||||
|
| 'volume'
|
||||||
|
| 'mount'
|
||||||
|
| 'path';
|
||||||
|
|
||||||
|
export type ServiceCategory = 'media' | 'infra' | 'monitoring' | 'ai' | 'storage' | 'other';
|
||||||
|
|
||||||
|
export type Status = 'running' | 'stopped' | 'unknown';
|
||||||
|
|
||||||
|
export type ViewMode = 'network' | 'host' | 'service' | 'filesystem' | 'full';
|
||||||
|
|
||||||
|
export interface NodeData {
|
||||||
|
ip?: string;
|
||||||
|
mac?: string;
|
||||||
|
status: Status;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
config?: string;
|
||||||
|
files?: string[];
|
||||||
|
importance: 1 | 2 | 3 | 4 | 5;
|
||||||
|
category?: ServiceCategory;
|
||||||
|
description?: string;
|
||||||
|
parentId?: string;
|
||||||
|
children?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopologyNode {
|
||||||
|
id: string;
|
||||||
|
type: NodeType;
|
||||||
|
name: string;
|
||||||
|
data: NodeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopologyEdge {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Host {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
type: 'physical' | 'vm' | 'container' | 'rpi5';
|
||||||
|
role: string;
|
||||||
|
containers: string[];
|
||||||
|
services?: string[];
|
||||||
|
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VLAN {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
subnet: string;
|
||||||
|
purpose?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WifiNetwork {
|
||||||
|
ssid: string;
|
||||||
|
vlan: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkInfo {
|
||||||
|
gateway: {
|
||||||
|
model: string;
|
||||||
|
ip: string;
|
||||||
|
};
|
||||||
|
vlans: VLAN[];
|
||||||
|
wifi: WifiNetwork[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceConfig {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
ports?: string[];
|
||||||
|
volumes?: string[];
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Proxmox Admin types (proxmox-admin skill) ---
|
||||||
|
|
||||||
|
export interface ProxmoxVM {
|
||||||
|
vmid: number;
|
||||||
|
name: string;
|
||||||
|
status: 'running' | 'stopped' | 'paused';
|
||||||
|
type: 'qemu' | 'lxc';
|
||||||
|
cpu: number;
|
||||||
|
mem: number;
|
||||||
|
maxmem: number;
|
||||||
|
disk: number;
|
||||||
|
maxdisk: number;
|
||||||
|
uptime: number;
|
||||||
|
node: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxmoxContainer {
|
||||||
|
vmid: number;
|
||||||
|
name: string;
|
||||||
|
status: 'running' | 'stopped';
|
||||||
|
type: 'lxc';
|
||||||
|
cpu: number;
|
||||||
|
mem: number;
|
||||||
|
maxmem: number;
|
||||||
|
disk: number;
|
||||||
|
maxdisk: number;
|
||||||
|
uptime: number;
|
||||||
|
node: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Network Engineer types (network-engineer skill) ---
|
||||||
|
|
||||||
|
export interface NetworkSegment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
vlanId?: number;
|
||||||
|
subnet: string;
|
||||||
|
gateway?: string;
|
||||||
|
purpose: string;
|
||||||
|
hostCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Infrastructure Monitoring types (infrastructure-monitoring skill) ---
|
||||||
|
|
||||||
|
export interface DiscoveryMetrics {
|
||||||
|
duration: number; // ms
|
||||||
|
hostCount: number;
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
67
src/utils/colors.test.ts
Normal file
67
src/utils/colors.test.ts
Normal 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
65
src/utils/colors.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
56
tailwind.config.js
Normal file
56
tailwind.config.js
Normal 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
25
tsconfig.json
Normal 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
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/CommandPalette.tsx","./src/components/FileBrowser.tsx","./src/components/Header.tsx","./src/components/LeftPanel.tsx","./src/components/RightPanel.tsx","./src/components/StaleWarning.tsx","./src/components/TerminalPanel.tsx","./src/components/Dashboard/HostChart.tsx","./src/components/Dashboard/MetricsBar.tsx","./src/components/Graph/TopologyGraph.tsx","./src/data/staticConfig.ts","./src/services/discovery.ts","./src/services/sshDiscovery.ts","./src/store/topologyStore.test.ts","./src/store/topologyStore.ts","./src/types/index.ts","./src/utils/colors.test.ts","./src/utils/colors.ts"],"version":"5.6.3"}
|
||||||
2
vite.config.d.ts
vendored
Normal file
2
vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
27
vite.config.js
Normal file
27
vite.config.js
Normal 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
28
vite.config.ts
Normal 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
19
vitest.config.ts
Normal 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/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user