This commit is contained in:
+4
-30
@@ -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} />;
|
||||
function App() {
|
||||
return <InventoryPage />;
|
||||
}
|
||||
};
|
||||
|
||||
return renderView();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user