Add React inventory UI with tree and node context panel
CI / basic-check (push) Has been cancelled

This commit is contained in:
Perplexity Bot
2026-08-04 19:25:39 +00:00
parent 4466e59684
commit 8b2ed677a7
7 changed files with 479 additions and 31 deletions
+5 -31
View File
@@ -1,33 +1,7 @@
import { useState } from 'react';
import './App.css';
import { TopologyView } from './pages/TopologyView';
import { InventoryView } from './pages/InventoryView';
import { IPsView } from './pages/IPsView';
import { DiscoveryJobsView } from './pages/DiscoveryJobsView';
import { ServicesView } from './pages/ServicesView';
import type { ViewKey } from './types';
import { InventoryPage } from './pages/InventoryPage';
export function App() {
const [current, setCurrent] = useState<ViewKey>('inventory');
const commonProps = { onNavigate: setCurrent };
const renderView = () => {
switch (current) {
case 'topology':
return <TopologyView {...commonProps} />;
case 'inventory':
return <InventoryView {...commonProps} />;
case 'ips':
return <IPsView {...commonProps} />;
case 'discovery':
return <DiscoveryJobsView {...commonProps} />;
case 'services':
return <ServicesView {...commonProps} />;
default:
return <InventoryView {...commonProps} />;
}
};
return renderView();
function App() {
return <InventoryPage />;
}
export default App;
@@ -0,0 +1,80 @@
import { useMemo, useState } from 'react';
import type { InventoryTreeNode } from '../../types/inventory';
import './inventory.css';
type Props = {
nodes: InventoryTreeNode[];
selectedId: string | null;
onSelect: (id: string) => void;
};
const typeIcon: Record<string, string> = {
site: '🏠',
area: '🗂️',
rack: '🧱',
physical_host: '🖥️',
vm: '📦',
lxc: '📦',
container: '📦',
app: '⚙️',
switch: '🔀',
firewall: '🛡️',
router: '📡',
ap: '📶',
camera: '📷',
storage: '💾',
other: '•',
};
function TreeNode({ node, selectedId, onSelect, level = 0 }: { node: InventoryTreeNode; selectedId: string | null; onSelect: (id: string) => void; level?: number }) {
const [open, setOpen] = useState(level < 1);
const hasChildren = node.children.length > 0 || node.children_count > 0;
const isSelected = selectedId === node.id;
return (
<div className="inventory-tree-node">
<div className={`inventory-tree-row ${isSelected ? 'selected' : ''}`} style={{ paddingLeft: `${level * 16 + 8}px` }}>
<button
className="inventory-tree-toggle"
onClick={() => hasChildren && setOpen((v) => !v)}
aria-label={open ? 'Collapse node' : 'Expand node'}
>
{hasChildren ? (open ? '▾' : '▸') : '·'}
</button>
<button className="inventory-tree-label" onClick={() => onSelect(node.id)}>
<span className="inventory-tree-icon">{typeIcon[node.type] ?? '•'}</span>
<span className="inventory-tree-name">{node.name}</span>
</button>
<div className="inventory-tree-badges">
{node.badges.map((badge) => (
<span key={badge} className="inventory-badge">{badge}</span>
))}
</div>
</div>
{open && node.children.length > 0 && (
<div>
{node.children.map((child) => (
<TreeNode key={child.id} node={child} selectedId={selectedId} onSelect={onSelect} level={level + 1} />
))}
</div>
)}
</div>
);
}
export function InventoryTree({ nodes, selectedId, onSelect }: Props) {
const firstNode = useMemo(() => nodes[0]?.id ?? null, [nodes]);
return (
<section className="inventory-panel inventory-tree-panel">
<div className="inventory-panel-header">
<h2>Inventory</h2>
<span className="inventory-muted">Root: {firstNode ?? 'n/a'}</span>
</div>
<div className="inventory-tree-list">
{nodes.map((node) => (
<TreeNode key={node.id} node={node} selectedId={selectedId} onSelect={onSelect} />
))}
</div>
</section>
);
}
@@ -0,0 +1,83 @@
import type { NodeContext } from '../../types/inventory';
import './inventory.css';
type Props = {
context: NodeContext | null;
loading?: boolean;
};
function RelationList({ title, items }: { title: string; items: NodeContext['relations']['incoming'] }) {
return (
<div className="inventory-context-block">
<h3>{title}</h3>
{items.length === 0 ? (
<p className="inventory-muted">Nessuna relazione</p>
) : (
<ul className="inventory-list">
{items.map((item) => (
<li key={item.id}>
<strong>{item.type}</strong> · {item.layer} · {item.source_id} {item.target_id}
</li>
))}
</ul>
)}
</div>
);
}
export function NodeContextPanel({ context, loading }: Props) {
if (loading) {
return (
<section className="inventory-panel inventory-context-panel">
<div className="inventory-panel-header"><h2>Dettaglio nodo</h2></div>
<p className="inventory-muted">Caricamento...</p>
</section>
);
}
if (!context) {
return (
<section className="inventory-panel inventory-context-panel">
<div className="inventory-panel-header"><h2>Dettaglio nodo</h2></div>
<p className="inventory-muted">Seleziona un nodo dall'inventory.</p>
</section>
);
}
const { item, parent, children, relations } = context;
return (
<section className="inventory-panel inventory-context-panel">
<div className="inventory-panel-header">
<h2>{item.name}</h2>
<span className="inventory-status">{item.type} · {item.status}</span>
</div>
<div className="inventory-context-grid">
<div className="inventory-context-block">
<h3>Physical</h3>
<p><strong>Parent:</strong> {parent ? parent.name : 'Nessuno'}</p>
<p><strong>Children:</strong> {children.length}</p>
{children.length > 0 && (
<ul className="inventory-list">
{children.map((child) => (
<li key={child.id}>{child.name} · {child.type}</li>
))}
</ul>
)}
</div>
<div className="inventory-context-block">
<h3>Object</h3>
<p><strong>ID:</strong> {item.id}</p>
<p><strong>Vendor:</strong> {item.vendor ?? 'n/a'}</p>
<p><strong>Model:</strong> {item.model ?? 'n/a'}</p>
<p><strong>Notes:</strong> {item.notes ?? 'n/a'}</p>
</div>
</div>
<RelationList title="Incoming relations" items={relations.incoming} />
<RelationList title="Outgoing relations" items={relations.outgoing} />
</section>
);
}
@@ -0,0 +1,124 @@
.inventory-layout {
display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
gap: 16px;
padding: 16px;
}
.inventory-panel {
background: #111827;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 16px;
color: #e5e7eb;
}
.inventory-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.inventory-panel-header h2,
.inventory-context-block h3 {
margin: 0;
}
.inventory-tree-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.inventory-tree-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 8px;
}
.inventory-tree-row.selected {
background: rgba(59, 130, 246, 0.16);
}
.inventory-tree-toggle,
.inventory-tree-label {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
}
.inventory-tree-label {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.inventory-tree-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inventory-tree-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.inventory-badge,
.inventory-status {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
color: #cbd5e1;
font-size: 12px;
}
.inventory-context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.inventory-context-block {
background: rgba(255,255,255,0.03);
border-radius: 10px;
padding: 12px;
}
.inventory-list {
margin: 8px 0 0;
padding-left: 18px;
}
.inventory-muted {
color: #94a3b8;
}
.inventory-empty,
.inventory-error {
padding: 24px;
color: #cbd5e1;
}
@media (max-width: 960px) {
.inventory-layout {
grid-template-columns: 1fr;
}
.inventory-context-grid {
grid-template-columns: 1fr;
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { EntityResponse, InventoryTreeNode, ListResponse, NodeContext } from '../types/inventory';
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function fetchInventoryTree(): Promise<InventoryTreeNode[]> {
const response = await fetch('/api/inventory/tree');
const data = await handleResponse<ListResponse<InventoryTreeNode>>(response);
return data.items;
}
export async function fetchNodeContext(id: string): Promise<NodeContext> {
const response = await fetch(`/api/inventory/nodes/${id}/context`);
const data = await handleResponse<EntityResponse<NodeContext>>(response);
return data.item;
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { InventoryTree } from '../components/inventory/InventoryTree';
import { NodeContextPanel } from '../components/inventory/NodeContextPanel';
import { fetchInventoryTree, fetchNodeContext } from '../lib/inventoryApi';
import type { InventoryTreeNode, NodeContext } from '../types/inventory';
import '../components/inventory/inventory.css';
function findFirstNode(nodes: InventoryTreeNode[]): string | null {
for (const node of nodes) {
if (node.id) return node.id;
}
return null;
}
export function InventoryPage() {
const [nodes, setNodes] = useState<InventoryTreeNode[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [context, setContext] = useState<NodeContext | null>(null);
const [loadingTree, setLoadingTree] = useState(true);
const [loadingContext, setLoadingContext] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
setLoadingTree(true);
fetchInventoryTree()
.then((items) => {
if (!active) return;
setNodes(items);
const first = findFirstNode(items);
setSelectedId(first);
})
.catch((err: Error) => {
if (!active) return;
setError(err.message);
})
.finally(() => {
if (active) setLoadingTree(false);
});
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!selectedId) return;
let active = true;
setLoadingContext(true);
fetchNodeContext(selectedId)
.then((item) => {
if (active) setContext(item);
})
.catch((err: Error) => {
if (active) setError(err.message);
})
.finally(() => {
if (active) setLoadingContext(false);
});
return () => {
active = false;
};
}, [selectedId]);
if (loadingTree) {
return <div className="inventory-empty">Caricamento inventory...</div>;
}
if (error) {
return <div className="inventory-error">Errore inventory: {error}</div>;
}
return (
<div className="inventory-layout">
<InventoryTree nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
<NodeContextPanel context={context} loading={loadingContext} />
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
export type ObjectType =
| 'site'
| 'area'
| 'rack'
| 'physical_host'
| 'vm'
| 'lxc'
| 'container'
| 'app'
| 'switch'
| 'firewall'
| 'router'
| 'ap'
| 'camera'
| 'storage'
| 'other';
export type ObjectStatus = 'active' | 'planned' | 'inactive' | 'retired' | 'unknown';
export type RelationType =
| 'contains'
| 'mounted_in'
| 'attached_to'
| 'connects_to'
| 'uplink_to'
| 'routes_via'
| 'bridges_to'
| 'depends_on'
| 'hosts_service'
| 'managed_by';
export type RelationLayer = 'physical' | 'network' | 'logical' | 'service';
export interface InventoryTreeNode {
id: string;
name: string;
type: ObjectType;
status: ObjectStatus;
badges: string[];
children_count: number;
children: InventoryTreeNode[];
}
export interface NetObject {
id: string;
name: string;
slug?: string | null;
type: ObjectType;
parent_id?: string | null;
status: ObjectStatus;
vendor?: string | null;
model?: string | null;
serial?: string | null;
notes?: string | null;
created_at: string;
updated_at: string;
}
export interface Relation {
id: string;
type: RelationType;
layer: RelationLayer;
source_id: string;
target_id: string;
direction: 'forward';
source: 'manual' | 'snmp' | 'lldp' | 'arp' | 'nmap' | 'inference' | 'import';
confidence: number;
status: 'active' | 'stale' | 'planned' | 'deleted';
attributes: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface NodeContext {
item: NetObject;
parent: NetObject | null;
children: NetObject[];
relations: {
incoming: Relation[];
outgoing: Relation[];
};
}
export interface ListResponse<T> {
count: number;
items: T[];
}
export interface EntityResponse<T> {
item: T;
}