From 8b2ed677a705df81a771397164e3764296079e75 Mon Sep 17 00:00:00 2001 From: Perplexity Bot Date: Tue, 4 Aug 2026 19:25:39 +0000 Subject: [PATCH] Add React inventory UI with tree and node context panel --- frontend/src/App.tsx | 36 +---- .../components/inventory/InventoryTree.tsx | 80 +++++++++++ .../components/inventory/NodeContextPanel.tsx | 83 ++++++++++++ .../src/components/inventory/inventory.css | 124 ++++++++++++++++++ frontend/src/lib/inventoryApi.ts | 20 +++ frontend/src/pages/InventoryPage.tsx | 78 +++++++++++ frontend/src/types/inventory.ts | 89 +++++++++++++ 7 files changed, 479 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/inventory/InventoryTree.tsx create mode 100644 frontend/src/components/inventory/NodeContextPanel.tsx create mode 100644 frontend/src/components/inventory/inventory.css create mode 100644 frontend/src/lib/inventoryApi.ts create mode 100644 frontend/src/pages/InventoryPage.tsx create mode 100644 frontend/src/types/inventory.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e32bb4d..39dd71c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('inventory'); - - const commonProps = { onNavigate: setCurrent }; - - const renderView = () => { - switch (current) { - case 'topology': - return ; - case 'inventory': - return ; - case 'ips': - return ; - case 'discovery': - return ; - case 'services': - return ; - default: - return ; - } - }; - - return renderView(); +function App() { + return ; } + +export default App; diff --git a/frontend/src/components/inventory/InventoryTree.tsx b/frontend/src/components/inventory/InventoryTree.tsx new file mode 100644 index 0000000..2aabb90 --- /dev/null +++ b/frontend/src/components/inventory/InventoryTree.tsx @@ -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 = { + 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 ( +
+
+ + +
+ {node.badges.map((badge) => ( + {badge} + ))} +
+
+ {open && node.children.length > 0 && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function InventoryTree({ nodes, selectedId, onSelect }: Props) { + const firstNode = useMemo(() => nodes[0]?.id ?? null, [nodes]); + return ( +
+
+

Inventory

+ Root: {firstNode ?? 'n/a'} +
+
+ {nodes.map((node) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/inventory/NodeContextPanel.tsx b/frontend/src/components/inventory/NodeContextPanel.tsx new file mode 100644 index 0000000..182b7a6 --- /dev/null +++ b/frontend/src/components/inventory/NodeContextPanel.tsx @@ -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 ( +
+

{title}

+ {items.length === 0 ? ( +

Nessuna relazione

+ ) : ( +
    + {items.map((item) => ( +
  • + {item.type} · {item.layer} · {item.source_id} → {item.target_id} +
  • + ))} +
+ )} +
+ ); +} + +export function NodeContextPanel({ context, loading }: Props) { + if (loading) { + return ( +
+

Dettaglio nodo

+

Caricamento...

+
+ ); + } + + if (!context) { + return ( +
+

Dettaglio nodo

+

Seleziona un nodo dall'inventory.

+
+ ); + } + + const { item, parent, children, relations } = context; + + return ( +
+
+

{item.name}

+ {item.type} · {item.status} +
+ +
+
+

Physical

+

Parent: {parent ? parent.name : 'Nessuno'}

+

Children: {children.length}

+ {children.length > 0 && ( +
    + {children.map((child) => ( +
  • {child.name} · {child.type}
  • + ))} +
+ )} +
+ +
+

Object

+

ID: {item.id}

+

Vendor: {item.vendor ?? 'n/a'}

+

Model: {item.model ?? 'n/a'}

+

Notes: {item.notes ?? 'n/a'}

+
+
+ + + +
+ ); +} diff --git a/frontend/src/components/inventory/inventory.css b/frontend/src/components/inventory/inventory.css new file mode 100644 index 0000000..5879e03 --- /dev/null +++ b/frontend/src/components/inventory/inventory.css @@ -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; + } +} diff --git a/frontend/src/lib/inventoryApi.ts b/frontend/src/lib/inventoryApi.ts new file mode 100644 index 0000000..52d3e8b --- /dev/null +++ b/frontend/src/lib/inventoryApi.ts @@ -0,0 +1,20 @@ +import type { EntityResponse, InventoryTreeNode, ListResponse, NodeContext } from '../types/inventory'; + +async function handleResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; +} + +export async function fetchInventoryTree(): Promise { + const response = await fetch('/api/inventory/tree'); + const data = await handleResponse>(response); + return data.items; +} + +export async function fetchNodeContext(id: string): Promise { + const response = await fetch(`/api/inventory/nodes/${id}/context`); + const data = await handleResponse>(response); + return data.item; +} diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx new file mode 100644 index 0000000..b532eef --- /dev/null +++ b/frontend/src/pages/InventoryPage.tsx @@ -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([]); + const [selectedId, setSelectedId] = useState(null); + const [context, setContext] = useState(null); + const [loadingTree, setLoadingTree] = useState(true); + const [loadingContext, setLoadingContext] = useState(false); + const [error, setError] = useState(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
Caricamento inventory...
; + } + + if (error) { + return
Errore inventory: {error}
; + } + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/types/inventory.ts b/frontend/src/types/inventory.ts new file mode 100644 index 0000000..faca4e8 --- /dev/null +++ b/frontend/src/types/inventory.ts @@ -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; + created_at: string; + updated_at: string; +} + +export interface NodeContext { + item: NetObject; + parent: NetObject | null; + children: NetObject[]; + relations: { + incoming: Relation[]; + outgoing: Relation[]; + }; +} + +export interface ListResponse { + count: number; + items: T[]; +} + +export interface EntityResponse { + item: T; +}