feat: expand discovery with systemd services, LXC/VMs, SSH terminal, and filebrowser
- Add systemd service discovery to backend - Add Proxmox LXC/VM detection - Add hostType field to config for better host categorization - Fix SSH trust between hosts (ubuntu/grizzley -> truenas/proxmox) - Add SSH terminal support via xterm.js - Add filebrowser for browsing host filesystems - Update frontend types and components for new node types
This commit is contained in:
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
|
||||||
899
package-lock.json
generated
899
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.4.4",
|
"@xyflow/react": "^12.4.4",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
|||||||
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
|
||||||
56
server/hosts.json
Normal file
56
server/hosts.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
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';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3001;
|
const PORT = 3001;
|
||||||
@@ -10,11 +16,25 @@ app.use(cors({
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({ status: 'ok' });
|
res.json({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Debug endpoint to check config
|
||||||
|
app.get('/api/debug-config', (req, res) => {
|
||||||
|
const hosts = getHostConfigs();
|
||||||
|
res.json({ hosts });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api', discoverRouter);
|
||||||
|
app.use('/api', configRouter);
|
||||||
|
app.use('/api', statsRouter);
|
||||||
|
app.use('/api', filesRouter);
|
||||||
|
app.use('/api', terminalRouter);
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running on http://localhost:${PORT}`);
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
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;
|
||||||
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;
|
||||||
@@ -17,6 +17,8 @@ export interface HostConfig {
|
|||||||
sshKeyPath?: string;
|
sshKeyPath?: string;
|
||||||
/** Optional SSH port (defaults to 22) */
|
/** Optional SSH port (defaults to 22) */
|
||||||
sshPort?: number;
|
sshPort?: number;
|
||||||
|
/** Host type: proxmox, truenas, docker-host, etc */
|
||||||
|
hostType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoveryResponse {
|
export interface DiscoveryResponse {
|
||||||
@@ -26,6 +28,8 @@ export interface DiscoveryResponse {
|
|||||||
ip: string;
|
ip: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
containers?: string[];
|
containers?: string[];
|
||||||
|
services?: string[];
|
||||||
|
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
/** Timestamp of discovery run */
|
/** Timestamp of discovery run */
|
||||||
|
|||||||
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)
|
||||||
147
src/App.tsx
147
src/App.tsx
@@ -3,16 +3,34 @@ import { ReactFlowProvider } from '@xyflow/react';
|
|||||||
import { useTopologyStore } from './store/topologyStore';
|
import { useTopologyStore } from './store/topologyStore';
|
||||||
import {
|
import {
|
||||||
defaultNetworkInfo,
|
defaultNetworkInfo,
|
||||||
defaultHosts,
|
|
||||||
discoverHosts,
|
discoverHosts,
|
||||||
convertToTopology
|
convertToTopology,
|
||||||
|
DiscoveredHost
|
||||||
} from './services/discovery';
|
} from './services/discovery';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import LeftPanel from './components/LeftPanel';
|
import LeftPanel from './components/LeftPanel';
|
||||||
import RightPanel from './components/RightPanel';
|
import RightPanel from './components/RightPanel';
|
||||||
import TopologyGraph from './components/Graph/TopologyGraph';
|
import TopologyGraph from './components/Graph/TopologyGraph';
|
||||||
|
import CommandPalette from './components/CommandPalette';
|
||||||
|
import StaleWarning from './components/StaleWarning';
|
||||||
|
import TerminalPanel from './components/TerminalPanel';
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 30000;
|
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() {
|
function App() {
|
||||||
const {
|
const {
|
||||||
@@ -22,26 +40,86 @@ function App() {
|
|||||||
setHosts,
|
setHosts,
|
||||||
setLastUpdated,
|
setLastUpdated,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
|
setDataSource,
|
||||||
|
incrementFailures,
|
||||||
|
resetFailures,
|
||||||
|
setLastSuccessfulDiscovery,
|
||||||
leftPanelOpen,
|
leftPanelOpen,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
isLoading
|
isLoading,
|
||||||
|
pollInterval,
|
||||||
|
toggleCommandPalette,
|
||||||
|
terminalOpen,
|
||||||
|
terminalHost,
|
||||||
|
closeTerminal
|
||||||
} = useTopologyStore();
|
} = useTopologyStore();
|
||||||
|
|
||||||
const isLoadingRef = useRef(isLoading);
|
const isLoadingRef = useRef(isLoading);
|
||||||
isLoadingRef.current = isLoading;
|
isLoadingRef.current = isLoading;
|
||||||
|
const pollIntervalRef = useRef(pollInterval);
|
||||||
|
pollIntervalRef.current = pollInterval;
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (isLoadingRef.current) return;
|
if (isLoadingRef.current) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hostNames = ['ubuntu', 'grizzley', 'truenas', 'ice', 'panda', 'proxmox'];
|
const response = await fetch(`${API_BASE_URL}/api/discover`, {
|
||||||
const discoveryResult = await discoverHosts(hostNames);
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
const { nodes, edges } = convertToTopology(
|
if (response.ok) {
|
||||||
discoveryResult.hosts,
|
const data: ApiDiscoveryResponse = await response.json();
|
||||||
defaultNetworkInfo
|
|
||||||
);
|
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 => ({
|
const hosts = discoveryResult.hosts.map(h => ({
|
||||||
name: h.name,
|
name: h.name,
|
||||||
@@ -60,29 +138,37 @@ function App() {
|
|||||||
setNetworkInfo(defaultNetworkInfo);
|
setNetworkInfo(defaultNetworkInfo);
|
||||||
setHosts(hosts);
|
setHosts(hosts);
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
} catch (error) {
|
setDataSource('simulated');
|
||||||
console.error('Discovery failed:', error);
|
incrementFailures();
|
||||||
setNodes([]);
|
|
||||||
setEdges([]);
|
|
||||||
setNetworkInfo(defaultNetworkInfo);
|
|
||||||
setHosts(defaultHosts);
|
|
||||||
setLastUpdated(new Date());
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [setNodes, setEdges, setNetworkInfo, setHosts, setLastUpdated, setIsLoading]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
}, []);
|
||||||
const intervalId = setInterval(loadData, POLLING_INTERVAL_MS);
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = setInterval(loadData, pollIntervalRef.current);
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<div className="h-screen w-screen flex flex-col bg-slate-900">
|
<div className="h-screen w-screen flex flex-col bg-slate-900">
|
||||||
|
<StaleWarning />
|
||||||
<Header onRefresh={loadData} isLoading={isLoading} />
|
<Header onRefresh={loadData} isLoading={isLoading} />
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
@@ -100,14 +186,20 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<CommandPalette />
|
||||||
|
{terminalOpen && terminalHost && (
|
||||||
|
<TerminalPanel host={terminalHost} onClose={closeTerminal} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Footer() {
|
function Footer() {
|
||||||
const { lastUpdated, nodes } = useTopologyStore();
|
const { lastUpdated, nodes, dataSource, pollInterval } = useTopologyStore();
|
||||||
const [countdown, setCountdown] = useState(30);
|
const [countdown, setCountdown] = useState(Math.ceil(pollInterval / 1000));
|
||||||
|
const pollIntervalRef = useRef(pollInterval);
|
||||||
|
pollIntervalRef.current = pollInterval;
|
||||||
|
|
||||||
const formatTime = (date: Date | null) => {
|
const formatTime = (date: Date | null) => {
|
||||||
if (!date) return 'Never';
|
if (!date) return 'Never';
|
||||||
@@ -115,22 +207,25 @@ function Footer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCountdown(30);
|
setCountdown(Math.ceil(pollInterval / 1000));
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
setCountdown(prev => {
|
setCountdown(prev => {
|
||||||
if (prev <= 1) return 30;
|
if (prev <= 1) return Math.ceil(pollIntervalRef.current / 1000);
|
||||||
return prev - 1;
|
return prev - 1;
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [lastUpdated]);
|
}, [lastUpdated, pollInterval]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="h-8 bg-slate-800 border-t border-slate-700 px-4 flex items-center justify-between text-xs text-slate-400">
|
||||||
<span>Nodes: {nodes.length}</span>
|
<span>Nodes: {nodes.length}</span>
|
||||||
<div className="flex items-center gap-4">
|
<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>Next refresh: {countdown}s</span>
|
||||||
<span>Last updated: {formatTime(lastUpdated)}</span>
|
<span>Last updated: {formatTime(lastUpdated)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,27 +24,36 @@ const nodeIcons: Record<NodeType, React.ReactNode> = {
|
|||||||
host_physical: <Server className="w-5 h-5" />,
|
host_physical: <Server className="w-5 h-5" />,
|
||||||
host_vm: <Server className="w-5 h-5" />,
|
host_vm: <Server className="w-5 h-5" />,
|
||||||
host_container: <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" />,
|
service: <Box className="w-4 h-4" />,
|
||||||
volume: <Database className="w-4 h-4" />,
|
volume: <Database className="w-4 h-4" />,
|
||||||
mount: <Database className="w-4 h-4" />,
|
mount: <Database className="w-4 h-4" />,
|
||||||
path: <Folder className="w-4 h-4" />,
|
path: <Folder className="w-4 h-4" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
function CustomNode({ data, selected }: NodeProps) {
|
function CustomNode({ data, selected, id }: NodeProps) {
|
||||||
|
const { highlightPath } = useTopologyStore();
|
||||||
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string };
|
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 nodeColor = getNodeColor(nodeData.type || 'service', nodeData.category);
|
||||||
const statusColor = getStatusColor(nodeData.status || 'unknown');
|
const statusColor = getStatusColor(nodeData.status || 'unknown');
|
||||||
|
const isHighlighted = highlightPath.includes(id);
|
||||||
|
const isDimmed = highlightPath.length > 0 && !isHighlighted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-4 py-3 rounded-xl border-2 transition-all ${
|
className={`px-4 py-3 rounded-xl border-2 transition-all ${
|
||||||
selected
|
selected
|
||||||
? 'border-sky-400 shadow-lg shadow-sky-400/20'
|
? 'border-sky-400 shadow-lg shadow-sky-400/20'
|
||||||
: 'border-slate-600 hover:border-slate-500'
|
: isHighlighted
|
||||||
|
? 'border-indigo-400 shadow-lg shadow-indigo-400/20'
|
||||||
|
: 'border-slate-600 hover:border-slate-500'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#1E293B',
|
backgroundColor: isDimmed ? '#0F172A' : '#1E293B',
|
||||||
minWidth: '140px'
|
minWidth: '140px',
|
||||||
|
opacity: isDimmed ? 0.4 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Handle type="target" position={Position.Left} className="!bg-slate-400" />
|
<Handle type="target" position={Position.Left} className="!bg-slate-400" />
|
||||||
@@ -127,7 +136,8 @@ export default function TopologyGraph() {
|
|||||||
setSelectedNode,
|
setSelectedNode,
|
||||||
getFilteredNodes,
|
getFilteredNodes,
|
||||||
orientation,
|
orientation,
|
||||||
viewMode
|
viewMode,
|
||||||
|
highlightPath
|
||||||
} = useTopologyStore();
|
} = useTopologyStore();
|
||||||
|
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
@@ -160,27 +170,36 @@ export default function TopologyGraph() {
|
|||||||
|
|
||||||
const newEdges: Edge[] = storeEdges
|
const newEdges: Edge[] = storeEdges
|
||||||
.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
||||||
.map(edge => ({
|
.map(edge => {
|
||||||
id: edge.id,
|
const isPathEdge = highlightPath.includes(edge.source) && highlightPath.includes(edge.target);
|
||||||
source: edge.source,
|
const isSelected = edge.source === selectedNodeId || edge.target === selectedNodeId;
|
||||||
target: edge.target,
|
return {
|
||||||
type: 'smoothstep',
|
id: edge.id,
|
||||||
animated: edge.source === selectedNodeId || edge.target === selectedNodeId,
|
source: edge.source,
|
||||||
style: {
|
target: edge.target,
|
||||||
stroke: edge.source === selectedNodeId || edge.target === selectedNodeId
|
type: 'smoothstep',
|
||||||
? '#38BDF8'
|
animated: isSelected || isPathEdge,
|
||||||
: '#475569',
|
style: {
|
||||||
strokeWidth: edge.source === selectedNodeId || edge.target === selectedNodeId
|
stroke: isSelected
|
||||||
? 2
|
? '#38BDF8'
|
||||||
: 1,
|
: isPathEdge
|
||||||
},
|
? '#818CF8'
|
||||||
markerEnd: {
|
: '#475569',
|
||||||
type: 'arrowclosed' as const,
|
strokeWidth: isSelected || isPathEdge
|
||||||
color: edge.source === selectedNodeId || edge.target === selectedNodeId
|
? 2
|
||||||
? '#38BDF8'
|
: 1,
|
||||||
: '#475569',
|
opacity: highlightPath.length > 0 && !isPathEdge ? 0.3 : 1
|
||||||
},
|
},
|
||||||
}));
|
markerEnd: {
|
||||||
|
type: 'arrowclosed' as const,
|
||||||
|
color: isSelected
|
||||||
|
? '#38BDF8'
|
||||||
|
: isPathEdge
|
||||||
|
? '#818CF8'
|
||||||
|
: '#475569',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
|
||||||
newNodes,
|
newNodes,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const typeIcons: Record<NodeType, React.ReactNode> = {
|
|||||||
host_physical: <Server className="w-4 h-4" />,
|
host_physical: <Server className="w-4 h-4" />,
|
||||||
host_vm: <Server className="w-4 h-4" />,
|
host_vm: <Server className="w-4 h-4" />,
|
||||||
host_container: <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" />,
|
service: <Box className="w-4 h-4" />,
|
||||||
volume: <Database className="w-4 h-4" />,
|
volume: <Database className="w-4 h-4" />,
|
||||||
mount: <Database className="w-4 h-4" />,
|
mount: <Database className="w-4 h-4" />,
|
||||||
@@ -23,6 +26,9 @@ const typeLabels: Record<NodeType, string> = {
|
|||||||
host_physical: 'Physical',
|
host_physical: 'Physical',
|
||||||
host_vm: 'VM',
|
host_vm: 'VM',
|
||||||
host_container: 'Container',
|
host_container: 'Container',
|
||||||
|
vm_lxc: 'LXC',
|
||||||
|
vm_qemu: 'QEMU',
|
||||||
|
systemd_service: 'Systemd',
|
||||||
service: 'Service',
|
service: 'Service',
|
||||||
volume: 'Volume',
|
volume: 'Volume',
|
||||||
mount: 'Mount',
|
mount: 'Mount',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Info, FileCode, FolderOpen, BarChart3, Star, X } from 'lucide-react';
|
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder } from 'lucide-react';
|
||||||
import { useTopologyStore } from '../store/topologyStore';
|
import { useTopologyStore } from '../store/topologyStore';
|
||||||
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors';
|
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors';
|
||||||
|
import FileBrowser from './FileBrowser';
|
||||||
|
|
||||||
type TabId = 'details' | 'config' | 'files' | 'usage' | 'importance';
|
type TabId = 'details' | 'config' | 'files' | 'usage' | 'importance';
|
||||||
|
|
||||||
@@ -14,8 +15,9 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function RightPanel() {
|
export default function RightPanel() {
|
||||||
const { nodes, selectedNodeId, setSelectedNode } = useTopologyStore();
|
const { nodes, selectedNodeId, setSelectedNode, openTerminal } = useTopologyStore();
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('details');
|
const [activeTab, setActiveTab] = useState<TabId>('details');
|
||||||
|
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||||
|
|
||||||
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
||||||
|
|
||||||
@@ -31,17 +33,38 @@ export default function RightPanel() {
|
|||||||
|
|
||||||
const nodeColor = getNodeColor(selectedNode.type, selectedNode.data.category);
|
const nodeColor = getNodeColor(selectedNode.type, selectedNode.data.category);
|
||||||
const statusColor = getStatusColor(selectedNode.data.status);
|
const statusColor = getStatusColor(selectedNode.data.status);
|
||||||
|
const isHost = selectedNode.type.startsWith('host_') || selectedNode.type.startsWith('vm_');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col">
|
<div className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col">
|
||||||
<div className="h-12 px-4 flex items-center justify-between border-b border-slate-700">
|
<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>
|
<h2 className="text-sm font-semibold text-white truncate">{selectedNode.name}</h2>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => setSelectedNode(null)}
|
{isHost && selectedNodeId && (
|
||||||
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
<>
|
||||||
>
|
<button
|
||||||
<X className="w-4 h-4 text-slate-400" />
|
onClick={() => setFileBrowserOpen(true)}
|
||||||
</button>
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
title="Browse Files"
|
||||||
|
>
|
||||||
|
<Folder className="w-4 h-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openTerminal(selectedNodeId)}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
title="Open Terminal"
|
||||||
|
>
|
||||||
|
<Terminal className="w-4 h-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedNode(null)}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex border-b border-slate-700">
|
<div className="flex border-b border-slate-700">
|
||||||
@@ -67,6 +90,13 @@ export default function RightPanel() {
|
|||||||
{activeTab === 'usage' && <UsageTab node={selectedNode} />}
|
{activeTab === 'usage' && <UsageTab node={selectedNode} />}
|
||||||
{activeTab === 'importance' && <ImportanceTab node={selectedNode} />}
|
{activeTab === 'importance' && <ImportanceTab node={selectedNode} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fileBrowserOpen && selectedNodeId && (
|
||||||
|
<FileBrowser
|
||||||
|
host={selectedNodeId}
|
||||||
|
onClose={() => setFileBrowserOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ export interface DiscoveredHost {
|
|||||||
ip: string;
|
ip: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
containers: DiscoveredContainer[];
|
containers: DiscoveredContainer[];
|
||||||
|
services?: string[];
|
||||||
|
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
||||||
cpuUsage?: number;
|
cpuUsage?: number;
|
||||||
memoryUsage?: number;
|
memoryUsage?: number;
|
||||||
uptime?: string;
|
uptime?: string;
|
||||||
@@ -225,6 +227,52 @@ export function convertToTopology(
|
|||||||
});
|
});
|
||||||
edges.push({ id: `e-${host.name}-${container.name}`, source: host.name, target: `${host.name}-${container.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 = {
|
const truenasNode: TopologyNode = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type StatusFilter = 'all' | 'running' | 'stopped';
|
|||||||
// All node types for default filter
|
// All node types for default filter
|
||||||
const ALL_NODE_TYPES: NodeType[] = [
|
const ALL_NODE_TYPES: NodeType[] = [
|
||||||
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container',
|
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container',
|
||||||
'service', 'volume', 'mount', 'path'
|
'vm_lxc', 'vm_qemu', 'systemd_service', 'service', 'volume', 'mount', 'path'
|
||||||
];
|
];
|
||||||
|
|
||||||
interface TopologyState {
|
interface TopologyState {
|
||||||
@@ -24,10 +24,21 @@ interface TopologyState {
|
|||||||
rightPanelOpen: boolean;
|
rightPanelOpen: boolean;
|
||||||
lastUpdated: Date | null;
|
lastUpdated: Date | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
pollInterval: number;
|
||||||
|
|
||||||
networkInfo: NetworkInfo | null;
|
networkInfo: NetworkInfo | null;
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
|
|
||||||
|
dataSource: 'live' | 'simulated';
|
||||||
|
consecutiveFailures: number;
|
||||||
|
lastSuccessfulDiscovery: Date | null;
|
||||||
|
|
||||||
|
commandPaletteOpen: boolean;
|
||||||
|
terminalOpen: boolean;
|
||||||
|
terminalHost: string | null;
|
||||||
|
highlightPath: string[];
|
||||||
|
staleWarningDismissed: boolean;
|
||||||
|
|
||||||
setNodes: (nodes: TopologyNode[]) => void;
|
setNodes: (nodes: TopologyNode[]) => void;
|
||||||
setEdges: (edges: TopologyEdge[]) => void;
|
setEdges: (edges: TopologyEdge[]) => void;
|
||||||
setSelectedNode: (nodeId: string | null) => void;
|
setSelectedNode: (nodeId: string | null) => void;
|
||||||
@@ -39,9 +50,19 @@ interface TopologyState {
|
|||||||
toggleLeftPanel: () => void;
|
toggleLeftPanel: () => void;
|
||||||
toggleRightPanel: () => void;
|
toggleRightPanel: () => void;
|
||||||
setLastUpdated: (date: Date) => void;
|
setLastUpdated: (date: Date) => void;
|
||||||
|
openTerminal: (host: string) => void;
|
||||||
|
closeTerminal: () => void;
|
||||||
setIsLoading: (loading: boolean) => void;
|
setIsLoading: (loading: boolean) => void;
|
||||||
|
setPollInterval: (interval: number) => void;
|
||||||
setNetworkInfo: (info: NetworkInfo) => void;
|
setNetworkInfo: (info: NetworkInfo) => void;
|
||||||
setHosts: (hosts: Host[]) => 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;
|
getSelectedNode: () => TopologyNode | null;
|
||||||
getChildNodes: () => TopologyNode[];
|
getChildNodes: () => TopologyNode[];
|
||||||
@@ -63,12 +84,34 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
rightPanelOpen: true,
|
rightPanelOpen: true,
|
||||||
lastUpdated: null,
|
lastUpdated: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
pollInterval: 30000,
|
||||||
networkInfo: null,
|
networkInfo: null,
|
||||||
hosts: [],
|
hosts: [],
|
||||||
|
dataSource: 'simulated',
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
lastSuccessfulDiscovery: null,
|
||||||
|
commandPaletteOpen: false,
|
||||||
|
terminalOpen: false,
|
||||||
|
terminalHost: null,
|
||||||
|
highlightPath: [],
|
||||||
|
staleWarningDismissed: false,
|
||||||
|
|
||||||
setNodes: (nodes) => set({ nodes }),
|
setNodes: (nodes) => set({ nodes }),
|
||||||
setEdges: (edges) => set({ edges }),
|
setEdges: (edges) => set({ edges }),
|
||||||
setSelectedNode: (nodeId) => set({ selectedNodeId: nodeId }),
|
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 }),
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
setOrientation: (orientation) => set({ orientation }),
|
setOrientation: (orientation) => set({ orientation }),
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
@@ -85,8 +128,18 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
toggleRightPanel: () => set((state) => ({ rightPanelOpen: !state.rightPanelOpen })),
|
toggleRightPanel: () => set((state) => ({ rightPanelOpen: !state.rightPanelOpen })),
|
||||||
setLastUpdated: (date) => set({ lastUpdated: date }),
|
setLastUpdated: (date) => set({ lastUpdated: date }),
|
||||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
setPollInterval: (interval) => set({ pollInterval: interval }),
|
||||||
setNetworkInfo: (info) => set({ networkInfo: info }),
|
setNetworkInfo: (info) => set({ networkInfo: info }),
|
||||||
setHosts: (hosts) => set({ hosts }),
|
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 }),
|
||||||
|
dismissStaleWarning: () => set({ staleWarningDismissed: true }),
|
||||||
|
openTerminal: (host) => set({ terminalOpen: true, terminalHost: host }),
|
||||||
|
closeTerminal: () => set({ terminalOpen: false, terminalHost: null }),
|
||||||
|
|
||||||
getSelectedNode: () => {
|
getSelectedNode: () => {
|
||||||
const { nodes, selectedNodeId } = get();
|
const { nodes, selectedNodeId } = get();
|
||||||
@@ -159,7 +212,7 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'homelab-topology-settings',
|
name: 'homelab-topology-settings',
|
||||||
version: 1,
|
version: 2,
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
partialize: (state: TopologyState) => ({
|
partialize: (state: TopologyState) => ({
|
||||||
viewMode: state.viewMode,
|
viewMode: state.viewMode,
|
||||||
@@ -168,6 +221,7 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
typeFilters: state.typeFilters,
|
typeFilters: state.typeFilters,
|
||||||
statusFilter: state.statusFilter,
|
statusFilter: state.statusFilter,
|
||||||
leftPanelOpen: state.leftPanelOpen,
|
leftPanelOpen: state.leftPanelOpen,
|
||||||
rightPanelOpen: state.rightPanelOpen
|
rightPanelOpen: state.rightPanelOpen,
|
||||||
|
pollInterval: state.pollInterval
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export type NodeType =
|
|||||||
| 'host_physical'
|
| 'host_physical'
|
||||||
| 'host_vm'
|
| 'host_vm'
|
||||||
| 'host_container'
|
| 'host_container'
|
||||||
|
| 'vm_lxc'
|
||||||
|
| 'vm_qemu'
|
||||||
|
| 'systemd_service'
|
||||||
| 'service'
|
| 'service'
|
||||||
| 'volume'
|
| 'volume'
|
||||||
| 'mount'
|
| 'mount'
|
||||||
@@ -50,6 +53,8 @@ export interface Host {
|
|||||||
type: 'physical' | 'vm' | 'container' | 'rpi5';
|
type: 'physical' | 'vm' | 'container' | 'rpi5';
|
||||||
role: string;
|
role: string;
|
||||||
containers: string[];
|
containers: string[];
|
||||||
|
services?: string[];
|
||||||
|
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VLAN {
|
export interface VLAN {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export function getNodeColor(type: NodeType, category?: ServiceCategory): string
|
|||||||
host_physical: '#10B981',
|
host_physical: '#10B981',
|
||||||
host_vm: '#14B8A6',
|
host_vm: '#14B8A6',
|
||||||
host_container: '#F59E0B',
|
host_container: '#F59E0B',
|
||||||
|
vm_lxc: '#22D3EE',
|
||||||
|
vm_qemu: '#06B6D4',
|
||||||
|
systemd_service: '#A78BFA',
|
||||||
service: getServiceColor(category),
|
service: getServiceColor(category),
|
||||||
volume: '#A855F7',
|
volume: '#A855F7',
|
||||||
mount: '#84CC16',
|
mount: '#84CC16',
|
||||||
|
|||||||
Reference in New Issue
Block a user